@liiift-studio/sanity-visitor-insights 0.78.0 → 0.78.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/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/studio/VisitorInsightsTool.tsx +26 -22
- package/src/studio/panels.test.tsx +44 -0
- package/src/studio/panels.tsx +4 -2
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/studio/VisitorInsightsTool.tsx","../src/studio/useReport.ts","../src/studio/urlState.ts","../src/core/ranges.ts","../src/studio/Figure.tsx","../src/studio/palette.ts","../src/types.ts","../src/studio/panels.tsx","../src/studio/CrossSourceTimeline.tsx","../src/core/rank.ts","../src/core/capture.ts","../src/core/cutover.ts","../src/core/siteConfig.ts"],"sourcesContent":["/**\n * Studio entry point — browser only.\n *\n * Must never import from `./server` or anything under `src/server/`, which reads credentials and\n * uses node:crypto. The split export subpaths are what keep that guarantee enforceable.\n */\n\nimport { definePlugin } from 'sanity'\nimport { resolveIcon } from '@liiift-studio/sanity-ui-compat/icons'\nimport { VisitorInsightsTool, type VisitorInsightsToolProps } from './studio/VisitorInsightsTool'\n\n/** Chart glyph, resolved against whichever @sanity/icons major the Studio has installed. */\nconst ChartIcon = resolveIcon('ChartUpwardIcon', 'chart-upward')\n\n/** Options for the Studio plugin. */\nexport interface VisitorInsightsPluginOptions {\n\t/**\n\t * Base URL of the site serving the reports, e.g. `https://dardenstudio.com`.\n\t * Same-origin studios can pass an empty string.\n\t */\n\tapiBaseUrl: string\n\t/** Label shown in the tool header. */\n\tsiteLabel: string\n\t/** Tool name in the Studio URL. Defaults to `visitor-insights`. */\n\tname?: string\n\t/** Title in the Studio nav. Defaults to `Insights`. */\n\ttitle?: string\n\t/**\n\t * Restrict the tool to these Sanity roles. Omit to show it to every Studio user.\n\t * These panels read order-derived conversion figures, so gating to administrators is\n\t * usually right — matching how the deploy and utilities tools are already gated.\n\t */\n\troles?: string[]\n}\n\n/**\n * Visitor Insights — visitor-behaviour analytics inside the Studio.\n *\n * @example\n * ```ts\n * plugins: [\n * visitorInsights({\n * apiBaseUrl: 'https://dardenstudio.com',\n * siteLabel: 'Darden Studio',\n * roles: ['administrator'],\n * }),\n * ]\n * ```\n */\nexport const visitorInsights = definePlugin<VisitorInsightsPluginOptions>((options) => {\n\tconst { apiBaseUrl, siteLabel, name = 'visitor-insights', title = 'Insights', roles } = options\n\n\treturn {\n\t\tname: '@liiift-studio/sanity-visitor-insights',\n\t\ttools: (prev, { currentUser }) => {\n\t\t\tif (roles && roles.length > 0) {\n\t\t\t\tconst userRoles = currentUser?.roles?.map((role) => role.name) ?? []\n\t\t\t\tconst permitted = userRoles.some((role) => roles.includes(role))\n\t\t\t\tif (!permitted) return prev\n\t\t\t}\n\n\t\t\treturn [\n\t\t\t\t...prev,\n\t\t\t\t{\n\t\t\t\t\tname,\n\t\t\t\t\ttitle,\n\t\t\t\t\ticon: ChartIcon,\n\t\t\t\t\tcomponent: VisitorInsightsTool,\n\t\t\t\t\toptions: { apiBaseUrl, siteLabel } satisfies VisitorInsightsToolProps,\n\t\t\t\t},\n\t\t\t]\n\t\t},\n\t}\n})\n\nexport default visitorInsights\n\nexport { VisitorInsightsTool, type VisitorInsightsToolProps } from './studio/VisitorInsightsTool'\nexport { knownShortfall, useReport, type ReportState, type UseReportOptions } from './studio/useReport'\nexport { decodeView, encodeView, mergeIntoHash, type ViewState } from './studio/urlState'\nexport { MetricFigure, Delta, FunnelChart, NoticeList, SortableTable, formatCount, formatMoney, formatPercent } from './studio/Figure'\nexport { ChartData, ProportionChart } from './studio/Figure'\nexport type { ChartDataProps, DeltaProps, ProportionBar, ProportionChartProps, SortColumn, SortableTableProps } from './studio/Figure'\nexport { CrossSourceTimeline } from './studio/CrossSourceTimeline'\nexport type { CrossSourceTimelineProps, Series, SeriesPoint, TimelineMarker } from './studio/CrossSourceTimeline'\nexport {\n\tAcquisitionPanel,\n\tDiagnosticsPanel,\n\tJourneyPanel,\n\tOverviewPanel,\n\tDataHealthPanel,\n\tTypefaceInterestPanel,\n} from './studio/panels'\n\n/**\n * @deprecated Renamed to `DataHealthPanel` in 0.22.0, when the panel was split into a business half\n * (`OverviewPanel`) and an instrument half. Kept as an alias because removing a public export in a\n * minor is a breaking change, and three sites upgrade this package separately.\n */\nexport { DataHealthPanel as MeasurementHealthPanel } from './studio/panels'\n\n// Shared contract types, safe in the browser — no server code reachable from here.\nexport * from './types'\nexport { PREEXISTING, coverageForRange, type EventCutover, type Coverage } from './core/cutover'\nexport { validateSiteConfig, type SiteAnalyticsConfig } from './core/siteConfig'\nexport { resolveRange, previousRange } from './core/ranges'\n// The response shapes. A consumer importing a panel could not name the type of the data it takes,\n// which is exactly the code that needs these types when the Studio and the route are version-skewed.\nexport type * from './reportData'\nexport type { CaptureBasis, CaptureEstimate, CaptureModel } from './core/capture'\n","/**\n * The Visitor Insights Studio tool.\n *\n * One range control drives every panel, implemented once here as a proper radiogroup with\n * roving tabindex — repeating a bespoke toggle per panel is how keyboard behaviour ends up\n * inconsistent between them.\n */\n\nimport React, { useCallback, useRef, useState } from 'react'\nimport { Box, Button, Card, Container, Flex, Heading, Spinner, Stack, Text } from '@liiift-studio/sanity-ui-compat'\nimport type { RangeKey, ReportName, SourceName, SourceStatus } from '../types'\nimport { knownShortfall, useReport, type ReportState } from './useReport'\nimport { decodeView, mergeIntoHash, type ViewState } from './urlState'\nimport type { ReportEnvelope } from '../types'\nimport { daysBetween, shiftDays } from '../core/ranges'\nimport { NoticeList, Section, panelStack } from './Figure'\nimport { COMPARISON, COMPARISON_STYLE, COMPARISON_TEXT, withAlpha } from './palette'\nimport { Badge } from '@liiift-studio/sanity-ui-compat'\nimport { AcquisitionPanel, DataHealthPanel, JourneyPanel, OverviewPanel, TypefaceInterestPanel } from './panels'\n\n/**\n * Control rows — range buttons, panel tabs, source status.\n *\n * Real CSS rather than the UI kit's Flex: when the compat shim cannot resolve Flex it renders a\n * plain div and a `gap` token does nothing, which is what put the Caveat badge on top of its own\n * text. Wrapping matters here too, since a Studio pane is resizable and five tabs do not fit a\n * narrow one.\n */\nconst controlRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: 6,\n\talignItems: 'center',\n\tflexWrap: 'wrap',\n}\n\n/**\n * Tab strip.\n *\n * The panels were five loose buttons in a row, which read as five actions rather than one choice\n * between five views — nothing said \"these are tabs\" except the ARIA role, which only a screen\n * reader ever heard. A shared baseline with the active tab underlined is the convention a reader\n * already knows, and it survives the compat shim falling back to plain elements because it is CSS\n * rather than a component variant.\n */\nconst tabStrip: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: 2,\n\talignItems: 'stretch',\n\tflexWrap: 'wrap',\n\tborderBottom: '1px solid var(--card-border-color, rgba(128,128,128,0.25))',\n}\n\n/** One tab. The selected state carries an underline as well as weight, never colour alone. */\nfunction tabStyle(selected: boolean): React.CSSProperties {\n\treturn {\n\t\tappearance: 'none',\n\t\tbackground: 'transparent',\n\t\tborder: 'none',\n\t\tborderBottom: `2px solid ${selected ? 'currentColor' : 'transparent'}`,\n\t\tcolor: 'inherit',\n\t\topacity: selected ? 1 : 0.62,\n\t\tfont: 'inherit',\n\t\tfontWeight: selected ? 600 : 400,\n\t\tpadding: '8px 12px',\n\t\tmarginBottom: -1,\n\t\tcursor: 'pointer',\n\t\twhiteSpace: 'nowrap',\n\t}\n}\n\n/** The context line under the tabs, saying what the panels are currently narrowed to. */\nconst contextRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: 12,\n\talignItems: 'baseline',\n\tflexWrap: 'wrap',\n}\n\n/** Clearing the narrowed span. Text, not a button-looking control — it is an undo, not an action. */\nconst inlineClear: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: 'none',\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: '0.8em',\n\topacity: 0.75,\n\tpadding: 0,\n\ttextDecoration: 'underline',\n\ttextUnderlineOffset: 3,\n\tcursor: 'pointer',\n}\n\n/** One range button. Selected state carries weight and a border, never colour alone. */\nfunction rangeButton(selected: boolean): React.CSSProperties {\n\treturn {\n\t\tappearance: 'none',\n\t\tbackground: 'transparent',\n\t\tborder: `1px solid ${selected ? 'currentColor' : 'var(--card-border-color, rgba(128,128,128,0.3))'}`,\n\t\tborderRadius: 3,\n\t\tcolor: 'inherit',\n\t\topacity: selected ? 1 : 0.7,\n\t\tfont: 'inherit',\n\t\tfontSize: '0.85em',\n\t\tfontWeight: selected ? 600 : 400,\n\t\tpadding: '5px 10px',\n\t\tcursor: 'pointer',\n\t\twhiteSpace: 'nowrap',\n\t}\n}\n\n/**\n * The comparison-basis toggle.\n *\n * Deliberately NOT `rangeButton`. It sat directly beneath the range selector in the same stack,\n * with the same border radius, weight and selected treatment, so nothing on screen connected it to\n * the figures it governs — and it read as a second row of ranges.\n *\n * Three channels separate them, only one of which is colour: a pill radius against the range row's\n * 3px, a dashed border on the unselected state, and COMPARISON on the selected one. A monochrome\n * or colour-blind reader still tells the two rows apart by shape alone.\n *\n * The tint sits well under 3:1 by design — its extent is carried by the 1px solid border, which is\n * at 4.28:1. That is the same fill-plus-boundary rule the palette registry enforces for regions.\n */\nfunction basisButton(selected: boolean): React.CSSProperties {\n\treturn {\n\t\tappearance: 'none',\n\t\tbackground: selected ? withAlpha(COMPARISON, 0.12) : 'transparent',\n\t\tborder: `1px ${selected ? 'solid' : 'dashed'} ${selected ? COMPARISON : 'var(--card-border-color, rgba(128,128,128,0.3))'}`,\n\t\tborderRadius: 999,\n\t\tcolor: selected ? COMPARISON_TEXT : 'inherit',\n\t\topacity: selected ? 1 : 0.7,\n\t\tfont: 'inherit',\n\t\tfontSize: '0.85em',\n\t\tfontWeight: selected ? 600 : 400,\n\t\tpadding: '5px 12px',\n\t\t// Pinned rather than left to emerge from padding and root font size, which is how a target\n\t\t// silently drops under the 24px minimum when someone adjusts either.\n\t\tminHeight: 24,\n\t\tcursor: 'pointer',\n\t\twhiteSpace: 'nowrap',\n\t}\n}\n\n/** The custom-range form, wrapping on a narrow pane. */\nconst pickerRow: React.CSSProperties = { display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }\n\n/** A labelled date field. */\nconst pickerField: React.CSSProperties = { display: 'grid', gap: 2 }\n\n/** Native date input, inheriting the Studio's type and colours. */\nconst dateInput: React.CSSProperties = {\n\tfont: 'inherit',\n\tfontSize: '0.85em',\n\tpadding: '4px 6px',\n\tborderRadius: 3,\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.3))',\n\tbackground: 'transparent',\n\tcolor: 'inherit',\n\tcolorScheme: 'light dark',\n}\n\n/**\n * Range options.\n *\n * Labelled by the span they actually cover rather than by a calendar word. \"Quarter\" and \"Year\"\n * were trailing day counts — 91 and 365 days ending today — not Q3 or a calendar year, and a reader\n * comparing them against anything calendar-aligned would have been comparing different things\n * without being told.\n *\n * The dates each one resolves to are NOT shown beside the buttons — only in each button's `title`\n * tooltip, which is invisible on touch and in a screenshot, and in the footer line beneath all the\n * content. An earlier version of this comment claimed otherwise; the intent was never implemented.\n */\nconst RANGES: Array<{ key: Exclude<RangeKey, 'custom'>; label: string; span: string }> = [\n\t{ key: 'week', label: 'Week', span: 'the last 7 days' },\n\t{ key: 'month', label: 'Month', span: 'the last 30 days' },\n\t{ key: 'quarter', label: 'Quarter', span: 'the last 91 days' },\n\t{ key: 'year', label: 'Year', span: 'the last 365 days' },\n]\n\n/** Panels, in the order they appear. */\n/**\n * Panels, in the order they appear.\n *\n * Acquisition leads because \"where did people come from\" is the question most often being asked.\n * Measurement health sits near the end deliberately: it is about the instrument rather than the\n * audience, and leading with it made an operational caveat the first thing anyone read. Diagnostics\n * stays last, since it is about configuration rather than visitors at all.\n */\n/**\n * The tabs, in the order a reader meets them.\n *\n * `id` is the tab; `report` is the primary envelope it needs. They are no longer the same thing,\n * because Overview and Data health are two halves of one report — the business half and the\n * instrument half — which used to be a single tab named for the instrument. Revenue, orders, the\n * mailing list and the cross-source timeline were all behind it, on tab four, under a blurb saying\n * it was about how much of reality each source sees.\n *\n * Still five tabs. Overview takes the slot freed by folding Diagnostics into Data health, which\n * answers the same question it did — can I trust this — and did not need a tab of its own.\n */\n/**\n * Tabs whose every figure comes from GA4 alone.\n *\n * These get the coverage ribbon. Overview and Data health are excluded because they compute and\n * state the shortfall themselves — a ribbon there would say the same thing twice.\n */\nconst GA4_ONLY_TABS = ['acquisition', 'journey', 'typeface-interest']\n\nconst PANELS: Array<{ id: string; report: ReportName; label: string }> = [\n\t{ id: 'overview', report: 'measurement-health', label: 'Overview' },\n\t{ id: 'acquisition', report: 'acquisition', label: 'Acquisition' },\n\t{ id: 'typeface-interest', report: 'typeface-interest', label: 'Typeface interest' },\n\t{ id: 'journey', report: 'journey', label: 'Journey' },\n]\n\n/** Options supplied by the plugin config, carried on the Sanity tool definition. */\nexport interface VisitorInsightsToolProps {\n\tapiBaseUrl: string\n\tsiteLabel: string\n}\n\n/**\n * What Sanity actually hands a tool component.\n *\n * Sanity does NOT spread a tool's `options` onto its component's props — it passes the whole tool\n * definition as `tool`, with the options nested inside. Destructuring `apiBaseUrl` straight off\n * props therefore yields undefined, and the first thing useReport does with it is call .replace(),\n * so every panel failed with \"Cannot read properties of undefined (reading 'replace')\".\n *\n * Both shapes are accepted: the nested one because that is what the Studio passes, and the flat one\n * because the component is exported for direct use and is mounted that way in tests.\n */\nexport interface VisitorInsightsToolComponentProps extends Partial<VisitorInsightsToolProps> {\n\ttool?: { options?: Partial<VisitorInsightsToolProps> }\n}\n\n/**\n * Range selector.\n * Arrow keys move between options and select as they go, which is the expected behaviour for a\n * radiogroup; Tab enters and leaves the group as a single stop.\n */\nfunction RangeSelector({\n\tvalue,\n\tcustom,\n\tonChange,\n\tonCustomChange,\n}: {\n\tvalue: RangeKey\n\tcustom: { start: string; end: string }\n\tonChange: (next: RangeKey) => void\n\tonCustomChange: (next: { start: string; end: string }) => void\n}): React.ReactElement {\n\tconst refs = useRef<Array<HTMLButtonElement | null>>([])\n\tconst [pickerOpen, setPickerOpen] = useState(value === 'custom')\n\t// The picker edits a DRAFT and lifts it only on Apply.\n\t//\n\t// The comment below promised apply-on-submit and delivered it only on first entry into custom\n\t// mode, because Apply was what set the range. After a brush the range is ALREADY custom, so\n\t// every keystroke in a date field updated the live range and fanned out to four upstreams —\n\t// spinning the year field is several full fetches against a ten-concurrent ceiling.\n\tconst [draft, setDraft] = useState(custom)\n\t// Re-seeded whenever the committed range changes from outside, e.g. by a brush, so opening the\n\t// picker shows the window the reader is actually looking at.\n\tReact.useEffect(() => { setDraft(custom) }, [custom.start, custom.end])\n\n\tconst onKeyDown = useCallback(\n\t\t(event: React.KeyboardEvent) => {\n\t\t\tconst currentIndex = RANGES.findIndex((r) => r.key === value)\n\t\t\tlet nextIndex: number | null = null\n\n\t\t\tif (event.key === 'ArrowRight' || event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % RANGES.length\n\t\t\tif (event.key === 'ArrowLeft' || event.key === 'ArrowUp') nextIndex = (currentIndex - 1 + RANGES.length) % RANGES.length\n\t\t\tif (event.key === 'Home') nextIndex = 0\n\t\t\tif (event.key === 'End') nextIndex = RANGES.length - 1\n\n\t\t\tif (nextIndex === null) return\n\n\t\t\tevent.preventDefault()\n\t\t\tconst next = RANGES[nextIndex]\n\t\t\tif (!next) return\n\n\t\t\tsetPickerOpen(false)\n\t\t\tonChange(next.key)\n\t\t\trefs.current[nextIndex]?.focus()\n\t\t},\n\t\t[value, onChange],\n\t)\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t<div style={controlRow}>\n\t\t\t<div role=\"radiogroup\" aria-label=\"Date range\" style={controlRow} onKeyDown={onKeyDown}>\n\t\t\t\t{RANGES.map((range, index) => {\n\t\t\t\t\tconst selected = range.key === value\n\t\t\t\t\treturn (\n\t\t\t\t\t\t// Plain buttons rather than the UI kit's: the compat shim's DOM fallback does not\n\t\t\t\t\t\t// forward `text`, which renders the whole selector as blank boxes on any Studio\n\t\t\t\t\t\t// version where it cannot resolve Button.\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={range.key}\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tref={(el: HTMLButtonElement | null) => {\n\t\t\t\t\t\t\t\trefs.current[index] = el\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\trole=\"radio\"\n\t\t\t\t\t\t\taria-checked={selected}\n\t\t\t\t\t\t\t// Only the selected option is in the tab order, so the group is one stop.\n\t\t\t\t\t\t\t// Falls back to the first option when nothing is selected. A roving tabindex\n\t\t\t\t\t\t\t// with no fallback dropped the whole group out of the tab order the moment\n\t\t\t\t\t\t\t// the range became `custom` — which is exactly what brushing sets, so the\n\t\t\t\t\t\t\t// reward for using the keyboard-reachable brush was a mouse-only range\n\t\t\t\t\t\t\t// control.\n\t\t\t\t\t\t\ttabIndex={selected || (!RANGES.some((r) => r.key === value) && index === 0) ? 0 : -1}\n\t\t\t\t\t\t\ttitle={`Ending today, covering ${range.span}`}\n\t\t\t\t\t\t\tstyle={rangeButton(selected)}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetPickerOpen(false)\n\t\t\t\t\t\t\t\tonChange(range.key)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{range.label}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)\n\t\t\t\t})}\n\n\t\t\t</div>\n\n\t\t\t{/* OUTSIDE the radiogroup element, not merely outside its arrow cycle. It was a tabbable\n\t\t\t non-radio child of role=\"radiogroup\" — an ARIA violation, and a second tab stop inside\n\t\t\t a group documented as being one. */}\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tstyle={rangeButton(value === 'custom')}\n\t\t\t\t\taria-expanded={pickerOpen}\n\t\t\t\t\tonClick={() => setPickerOpen((open) => !open)}\n\t\t\t\t>\n\t\t\t\t\t{value === 'custom' ? `${custom.start} to ${custom.end}` : 'Custom…'}\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t{pickerOpen && (\n\t\t\t\t<div style={pickerRow}>\n\t\t\t\t\t<label style={pickerField}>\n\t\t\t\t\t\t<Text size={0} muted>From</Text>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"date\"\n\t\t\t\t\t\t\tvalue={draft.start}\n\t\t\t\t\t\t\tmax={draft.end || undefined}\n\t\t\t\t\t\t\tstyle={dateInput}\n\t\t\t\t\t\t\tonChange={(e) => setDraft((d) => ({ ...d, start: e.currentTarget.value }))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</label>\n\t\t\t\t\t<label style={pickerField}>\n\t\t\t\t\t\t<Text size={0} muted>To</Text>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"date\"\n\t\t\t\t\t\t\tvalue={draft.end}\n\t\t\t\t\t\t\tmin={draft.start || undefined}\n\t\t\t\t\t\t\tstyle={dateInput}\n\t\t\t\t\t\t\tonChange={(e) => setDraft((d) => ({ ...d, end: e.currentTarget.value }))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</label>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tstyle={rangeButton(true)}\n\t\t\t\t\t\t// Applied on submit, not on each keystroke: a half-typed year is a valid-looking\n\t\t\t\t\t\t// date, and refetching per character would fire a run of requests nobody asked for.\n\t\t\t\t\t\tdisabled={!draft.start || !draft.end}\n\t\t\t\t\t\tonClick={() => { onCustomChange(draft); onChange('custom') }}\n\t\t\t\t\t>\n\t\t\t\t\t\tApply\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/** Human labels for the upstreams, so the row does not read as internal jargon. */\nconst SOURCE_LABEL: Record<SourceName, string> = {\n\tga4: 'Google Analytics',\n\tvercel: 'Vercel',\n\tsanity: 'Orders',\n\tmailchimp: 'Mailchimp',\n}\n\n/**\n * Which sources answered.\n *\n * The envelope has carried this from the start and nothing displayed it, so a panel built on two\n * of three sources looked identical to one built on all three. Partial failure is the normal case\n * here — an unconfigured Vercel project, an expired service account — and the difference between\n * \"this number is low\" and \"we could not ask\" has to be visible.\n *\n * Renders nothing when everything answered, so the healthy case stays quiet.\n */\nfunction SourceStatusRow({ sources }: { sources: Partial<Record<SourceName, SourceStatus>> }): React.ReactElement | null {\n\tconst degraded = (Object.entries(sources) as Array<[SourceName, SourceStatus]>).filter(([, s]) => s.status !== 'ok')\n\tif (degraded.length === 0) return null\n\n\treturn (\n\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t<Flex gap={3} align=\"center\" wrap=\"wrap\">\n\t\t\t\t<Text size={1} weight=\"semibold\">Incomplete data:</Text>\n\t\t\t\t{degraded.map(([name, status]) => (\n\t\t\t\t\t<Flex key={name} gap={2} align=\"center\">\n\t\t\t\t\t\t<Badge tone={status.status === 'error' ? 'critical' : 'default'} fontSize={0}>\n\t\t\t\t\t\t\t{SOURCE_LABEL[name]}\n\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\t\t{status.status === 'error' ? status.message : 'not configured for this site'}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Flex>\n\t\t\t\t))}\n\t\t\t</Flex>\n\t\t</Card>\n\t)\n}\n\n/**\n * Panel tabs.\n *\n * Hand-rolled rather than taken from @sanity/ui so the keyboard contract is explicit and identical\n * across the Studio versions this package supports: arrow keys move and select, Tab is a single\n * stop into the group, and each tab is wired to its panel by id.\n */\nfunction PanelTabs({ value, onChange }: { value: string; onChange: (next: string) => void }): React.ReactElement {\n\tconst refs = useRef<Array<HTMLButtonElement | null>>([])\n\n\tconst onKeyDown = useCallback(\n\t\t(event: React.KeyboardEvent) => {\n\t\t\tconst currentIndex = PANELS.findIndex((p) => p.id === value)\n\t\t\tlet nextIndex: number | null = null\n\n\t\t\tif (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % PANELS.length\n\t\t\tif (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + PANELS.length) % PANELS.length\n\t\t\tif (event.key === 'Home') nextIndex = 0\n\t\t\tif (event.key === 'End') nextIndex = PANELS.length - 1\n\n\t\t\tif (nextIndex === null) return\n\n\t\t\tevent.preventDefault()\n\t\t\tconst next = PANELS[nextIndex]\n\t\t\tif (!next) return\n\n\t\t\tonChange(next.id)\n\t\t\trefs.current[nextIndex]?.focus()\n\t\t},\n\t\t[value, onChange],\n\t)\n\n\treturn (\n\t\t<div role=\"tablist\" aria-label=\"Report\" style={tabStrip} onKeyDown={onKeyDown}>\n\t\t\t{PANELS.map((panel, index) => {\n\t\t\t\tconst selected = panel.id === value\n\t\t\t\treturn (\n\t\t\t\t\t// A plain button rather than the UI kit's, so the label is real children and\n\t\t\t\t\t// cannot vanish: the compat shim's DOM fallback does not forward `text`, which\n\t\t\t\t\t// would leave five blank tabs on any Studio version where it falls back.\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={panel.id}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tref={(el: HTMLButtonElement | null) => {\n\t\t\t\t\t\t\trefs.current[index] = el\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tid={`tab-${panel.id}`}\n\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\taria-selected={selected}\n\t\t\t\t\t\taria-controls={`panel-${panel.id}`}\n\t\t\t\t\t\ttabIndex={selected ? 0 : -1}\n\t\t\t\t\t\tstyle={tabStyle(selected)}\n\t\t\t\t\t\tonClick={() => onChange(panel.id)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{panel.label}\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n}\n\n/**\n * How much of reality the figures below are drawn from, on the tabs that cannot say it themselves.\n *\n * The tool used to declare \"treat its figures as broken\" on Overview and then hand the reader three\n * screens of the same instrument's output with no marker at all. This is not a caveat about the\n * tool; it is a multiplier on every number under it, which is why it sits above them and states the\n * figure rather than a mood.\n *\n * Renders nothing when the shortfall is unknown or small. Unknown has to be silent: a panel that\n * cannot say how lossy its source is must not imply the source is fine.\n *\n * @param ratio - the fraction of reality GA4 is MISSING, 0 to 1\n */\nfunction CoverageRibbon({ ratio }: { ratio: number | null }): React.ReactElement | null {\n\t/*\n\t * Unknown is said, not left blank.\n\t *\n\t * The shortfall is learned only when a measurement-health envelope resolves for this exact\n\t * window — which happens on Overview and Data health. Arrive on Acquisition by link, or change\n\t * the range while sitting here, and it is simply not known: the ribbon rendered nothing, which\n\t * on a tab built entirely from GA4 is indistinguishable from \"coverage is fine\". A tool whose\n\t * subject is the difference between \"measured nothing\" and \"did not measure\" cannot make that\n\t * mistake about itself.\n\t */\n\tif (ratio === null) {\n\t\treturn (\n\t\t\t<Text size={0} muted>\n\t\t\t\tEvery figure on this tab comes from Google Analytics. How much of your traffic it is seeing\n\t\t\t\thas not been checked for this window — open Overview or Data health to measure it.\n\t\t\t</Text>\n\t\t)\n\t}\n\n\t// Below a fifth the pageview comparison is not distinguishable from crawlers and prefetches\n\t// Vercel counts and GA4 never sees, so a ribbon there would cry wolf on every load.\n\tif (ratio < 0.2) return null\n\n\tconst seen = Math.round((1 - ratio) * 100)\n\t// Coarse on purpose. \"5.0×\" is a point estimate with a decimal place, printed larger and louder\n\t// than anything else in the tool, on a quantity every other surface here wraps in an interval\n\t// and an Estimated badge. The multiplier is the actionable direction — that was the reason for\n\t// stating it — but it does not get to claim a precision the figure behind it has not earned.\n\tconst multiplier = seen > 0 ? Math.round(100 / seen) : null\n\treturn (\n\t\t<Card padding={3} radius={2} tone={ratio > 0.6 ? 'critical' : 'caution'} border>\n\t\t\t{/* Sixty-two words, on three tabs, on every load. Cut to the two claims a reader has to\n\t\t\t act on: how low these figures run, and which ones not to touch.\n\t\t\t\n\t\t\t \"anything labelled as coming from Sanity\" went because it names a system the reader\n\t\t\t has no reason to recognise — they know it as where they type. The column headers\n\t\t\t already carry (GA4) and (orders), which is the same distinction in the place it\n\t\t\t applies. \"Data health explains the rest\" went because the tab is named in the strip\n\t\t\t directly above this card. */}\n\t\t\t<Text size={1}>\n\t\t\t\tGoogle Analytics is seeing about {seen}% of your traffic here, so treat the figures below\n\t\t\t\tas roughly {multiplier === null ? 'many times' : `${multiplier}×`} low. Anything marked\n\t\t\t\t(orders) is exact — do not scale it.\n\t\t\t</Text>\n\t\t</Card>\n\t)\n}\n\n/**\n * The ready state of a report panel: everything drawn once an envelope has arrived.\n *\n * Extracted as a PURE component, taking the resolved envelope rather than the fetch state, so it can\n * be rendered in a test. Nothing in the suite mounted `ReportPanel` — it only reaches this markup\n * after a fetch resolves, which needs effects and a DOM the tests do not have — and in that blind\n * spot two structural bugs shipped, the second of which left this whole block nested inside a\n * caption and blanked three of the five tabs. Type-checking cannot see JSX nesting; a render can.\n */\nexport function ReadyReport({\n\tenvelope,\n\ttabId,\n\tapiBaseUrl,\n\trange,\n\tcustom,\n\trevalidationError,\n\tdiagnostics,\n\tonBrush,\n}: {\n\tenvelope: ReportEnvelope<unknown>\n\ttabId: string\n\tapiBaseUrl: string\n\trange: RangeKey\n\tcustom: { start: string; end: string }\n\t/** Message from a failed background refresh, or null. */\n\trevalidationError: string | null\n\t/** The configuration checks, shown only on Data health. */\n\tdiagnostics: ReportState<unknown>\n\tonBrush?: (start: string, end: string) => void\n}): React.ReactElement {\n\treturn (\n\t\t\t\t<Stack space={4}>\n\t\t\t\t\t{/*\n\t\t\t\t\t * Above the figures, because each of these three changes how the figures are\n\t\t\t\t\t * READ. That is the axis, not instrument-versus-business — the sort used when\n\t\t\t\t\t * this last moved was wrong on exactly the cases where the blocks exist. The\n\t\t\t\t\t * source row renders only when a source failed, so when it is here at all it\n\t\t\t\t\t * explains why a panel below is empty; the revalidation card says the figures\n\t\t\t\t\t * are stale before they are read rather than after; and the comparison sentence\n\t\t\t\t\t * defines every delta underneath it, which makes it a legend, not a footnote.\n\t\t\t\t\t * The standing caveats stay below the answer: those do not change day to day,\n\t\t\t\t\t * and they were the boilerplate that used to be the peak of the page.\n\t\t\t\t\t */}\n\t\t\t\t\t<SourceStatusRow sources={envelope.sources} />\n\t\t\t\t\t{revalidationError && (\n\t\t\t\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\tThese figures are the last ones that loaded. Refreshing them failed: {revalidationError}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t)}\n\t\t\t\t\t{/* Shown only on the panels that actually draw a delta, and gated on the TAB rather\n\t\t\t\t\t than the report — Data health's report is measurement-health, so gating on the\n\t\t\t\t\t report printed \"Changes are against…\" above a panel that draws not one delta.\n\n\t\t\t\t\t This <Text> is CLOSED here. It was not: an earlier reorder left it open, and\n\t\t\t\t\t every panel body, the coverage ribbon and the notices became children of it —\n\t\t\t\t\t so the three tabs outside COMPARED_TABS rendered nothing but the footer, and\n\t\t\t\t\t the two that did render drew the whole report inside a size-0 muted caption.\n\t\t\t\t\t It compiled, it type-checked, and no test mounted this component. */}\n\t\t\t\t\t{envelope.comparison && COMPARED_TABS.includes(tabId) && (\n\t\t\t\t\t\t<Text size={0} style={{ color: COMPARISON_TEXT }}>\n\t\t\t\t\t\t\tChanges are against {envelope.comparison.range.start} to {envelope.comparison.range.end},\n\t\t\t\t\t\t\t{/* Named, not implied. Once the baseline can be chosen, a sentence that says\n\t\t\t\t\t\t\t only \"the equivalent window\" leaves the reader to work out which one is in\n\t\t\t\t\t\t\t force from the dates — and a year-ago comparison misread as last month's\n\t\t\t\t\t\t\t is a wrong conclusion, not a cosmetic one. */}\n\t\t\t\t\t\t\t{envelope.comparison.basis === 'same-period-last-year'\n\t\t\t\t\t\t\t\t? ' the same window a year earlier.'\n\t\t\t\t\t\t\t\t: ' the equivalent window immediately before this one.'}\n\t\t\t\t\t\t\t{envelope.comparison.provisional && (\n\t\t\t\t\t\t\t\t<> This window’s last days are still being processed by GA4, so changes read low.</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{GA4_ONLY_TABS.includes(tabId) && (\n\t\t\t\t\t\t<CoverageRibbon ratio={knownShortfall(apiBaseUrl, range, range === 'custom' ? custom : undefined)} />\n\t\t\t\t\t)}\n\n\t\t\t\t\t{tabId === 'overview' && (\n\t\t\t\t\t\t<div style={panelStack}>\n\t\t\t\t\t\t\t<OverviewPanel data={envelope.data as never} previous={envelope.comparison?.data as never} onBrush={onBrush} />\n\t\t\t\t\t\t\t{/* The instrument, on the same page as the figures it qualifies, folded.\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t The containment bar states one total and the timeline's coverage row states\n\t\t\t\t\t\t\t the same quantity daily; dating a collapse means reading the shape and the\n\t\t\t\t\t\t\t total together, and a tab forbade exactly that. Folded because it is\n\t\t\t\t\t\t\t consulted rather than read — and safe to fold because Section keeps its\n\t\t\t\t\t\t\t subtitle outside the fold, so the reading itself stays on screen. */}\n\t\t\t\t\t\t\t<Section\n\t\t\t\t\t\t\t\ttitle=\"Can I trust these figures\"\n\t\t\t\t\t\t\t\ttone=\"secondary\"\n\t\t\t\t\t\t\t\tsubtitle={trustSubtitle(envelope.data as never)}\n\t\t\t\t\t\t\t\tcollapsible\n\t\t\t\t\t\t\t\tdefaultOpen={false}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<DataHealthPanel\n\t\t\t\t\t\t\t\t\tdata={envelope.data as never}\n\t\t\t\t\t\t\t\t\tdiagnostics={diagnostics.status === 'ready' ? (diagnostics.envelope.data as never) : undefined}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t{/* Said, not silently absent. The configuration block rendered only on\n\t\t\t\t\t\t\t\t `ready`, so an in-flight fetch or a 500 showed nothing at all and the\n\t\t\t\t\t\t\t\t section popped in later with no explanation of where it had been. */}\n\t\t\t\t\t\t\t\t{diagnostics.status === 'loading' && <Text size={1} muted>Checking configuration…</Text>}\n\t\t\t\t\t\t\t\t{diagnostics.status === 'error' && (\n\t\t\t\t\t\t\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t\t\t\t\t\t\t<Text size={1}>Configuration checks could not run: {diagnostics.message}</Text>\n\t\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</Section>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{tabId === 'acquisition' && <AcquisitionPanel data={envelope.data as never} previous={envelope.comparison?.data as never} />}\n\t\t\t\t\t{tabId === 'journey' && <JourneyPanel data={envelope.data as never} />}\n\t\t\t\t\t{tabId === 'typeface-interest' && <TypefaceInterestPanel data={envelope.data as never} />}\n\n\t\t\t\t\t{/* The standing caveats, below the answer. These read the same most days — a\n\t\t\t\t\t source that is configured but lossy, an event with no cutover date — so at the\n\t\t\t\t\t top they were amber wallpaper above every figure in the tool. */}\n\t\t\t\t\t<NoticeList notices={envelope.notices} />\n\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\tFigures cover {envelope.range.start} to {envelope.range.end}, in {envelope.range.timezone}\n\t\t\t\t\t</Text>\n\t\t\t\t</Stack>\n\t)\n}\n\n/**\n * Which baseline the deltas are drawn against.\n *\n * Type sales are seasonal — a foundry's quiet August against a quiet July says far less than\n * August against last August — and the preceding window was the only baseline available. Both cost\n * the same: the server fetches one comparison window either way, just a different one.\n */\nfunction BasisSelector({\n\tvalue,\n\tonChange,\n}: {\n\tvalue: 'previous-period' | 'same-period-last-year'\n\tonChange: (value: 'previous-period' | 'same-period-last-year') => void\n}): React.ReactElement {\n\t// `group`, not `radiogroup`. A radiogroup has to own role=\"radio\" children with aria-checked and\n\t// roving arrow-key focus; these are toggle buttons with a text label beside them, and claiming\n\t// the stronger role told a screen reader to expect navigation that does not exist.\n\treturn (\n\t\t<div style={controlRow} role=\"group\" aria-label=\"Comparison basis\">\n\t\t\t{/* Not muted: this label is the head of the comparison identity, and every delta on the\n\t\t\t panel is drawn in the same colour. */}\n\t\t\t<Text size={0} style={{ color: COMPARISON_TEXT }}>Compared with</Text>\n\t\t\t{/* Plain buttons for the same reason the range selector uses them: the compat shim's DOM\n\t\t\t fallback does not forward `text`, so the UI kit's Button renders as a blank box. */}\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tstyle={basisButton(value === 'previous-period')}\n\t\t\t\taria-pressed={value === 'previous-period'}\n\t\t\t\ttitle=\"The equivalent window immediately before this one\"\n\t\t\t\tonClick={() => onChange('previous-period')}\n\t\t\t>\n\t\t\t\tPrevious period\n\t\t\t</button>\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tstyle={basisButton(value === 'same-period-last-year')}\n\t\t\t\taria-pressed={value === 'same-period-last-year'}\n\t\t\t\ttitle=\"The same window a year earlier, shifted by 364 days so the weekdays line up\"\n\t\t\t\tonClick={() => onChange('same-period-last-year')}\n\t\t\t>\n\t\t\t\tLast year\n\t\t\t</button>\n\t\t</div>\n\t)\n}\n\n/**\n * The one-line answer that stands in for the trust band while it is folded.\n *\n * `Section` renders a subtitle outside the fold, which is the mechanism that makes folding legal in\n * a package whose founding rule is that a caveat sits beside its figure. So the coverage reading —\n * the single fact a reader wants before trusting anything above — is what goes here, rather than a\n * description of what is inside.\n *\n * @param data - the measurement-health envelope\n */\nfunction trustSubtitle(data: { shortfallRatio?: number | null } | undefined): string {\n\tconst shortfall = data?.shortfallRatio\n\tif (shortfall === null || shortfall === undefined || !Number.isFinite(shortfall)) {\n\t\treturn 'How much of your traffic Google Analytics is seeing, and what it is missing.'\n\t}\n\tconst seeing = Math.round((1 - shortfall) * 100)\n\treturn shortfall >= 0\n\t\t? `Google Analytics is seeing about ${seeing}% of your traffic. Open for how that was measured.`\n\t\t: 'Google Analytics is counting more than your server does. Open for how that was measured.'\n}\n\n/** Renders one report panel, including its loading, error and empty states. */\nfunction ReportPanel({\n\ttabId,\n\treport,\n\tapiBaseUrl,\n\trange,\n\tcustom,\n\tcompare,\n\tonBrush,\n}: {\n\t/** The tab being drawn. No longer the same as `report`: two tabs share one envelope. */\n\ttabId: string\n\treport: ReportName\n\tapiBaseUrl: string\n\trange: RangeKey\n\tcustom: { start: string; end: string }\n\tcompare?: 'previous-period' | 'same-period-last-year'\n\tonBrush?: (start: string, end: string) => void\n}): React.ReactElement {\n\tconst { state, reload } = useReport<unknown>({ apiBaseUrl, report, range, custom, compare })\n\t// Data health additionally shows the configuration checks. Fetched only on that tab, so the\n\t// four tabs that do not show them do not pay for them.\n\t// A fixed range: runDiagnostics takes none — it probes the present configuration — so keying it\n\t// on the selected range fired an identical set of live GA4, Vercel and Sanity probes on every\n\t// Week→Month→Quarter switch and cached three copies of one answer.\n\tconst diagnostics = useReport<unknown>({\n\t\tapiBaseUrl,\n\t\treport: 'diagnostics',\n\t\trange: 'week',\n\t\tenabled: tabId === 'overview',\n\t}).state\n\n\t// Announced to screen readers when figures change, so a range switch is perceivable without\n\t// re-navigating the whole panel.\n\tconst liveMessage =\n\t\tstate.status === 'loading'\n\t\t\t? 'Loading report'\n\t\t\t: state.status === 'ready'\n\t\t\t\t? `${PANELS.find((p) => p.id === tabId)?.label ?? tabId} updated for ${range === 'custom' ? `${custom.start} to ${custom.end}` : `the selected ${range}`}`\n\t\t\t\t: state.status === 'error'\n\t\t\t\t\t? state.disabled\n\t\t\t\t\t\t? 'Visitor insights is switched off for this site'\n\t\t\t\t\t\t: `Report failed: ${state.message}`\n\t\t\t\t\t: ''\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Box aria-live=\"polite\" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }}>\n\t\t\t\t{liveMessage}\n\t\t\t</Box>\n\n\t\t\t{state.status === 'ready' && state.stale && (\n\t\t\t\t// A quiet marker, and nothing else. The panel used to render at 0.55 opacity while\n\t\t\t\t// stale — but every cache hit and every range change sets that flag, so the dominant\n\t\t\t\t// felt state of the tool was \"slightly unreadable\", and muted text at 55% is under\n\t\t\t\t// 3:1 in both themes. The line says it; the figures stay legible.\n\t\t\t\t<Text size={0} muted>Updating…</Text>\n\t\t\t)}\n\n\t\t\t{state.status === 'loading' && (\n\t\t\t\t<Flex align=\"center\" gap={3} padding={4}>\n\t\t\t\t\t<Spinner muted />\n\t\t\t\t\t<Text size={1} muted>Loading…</Text>\n\t\t\t\t</Flex>\n\t\t\t)}\n\n\t\t\t{state.status === 'error' && (\n\t\t\t\t// A switched-off site is a configuration state, not a fault: it gets a neutral card\n\t\t\t\t// and no retry, because retrying cannot change the answer. Everything else keeps\n\t\t\t\t// the critical tone and the retry.\n\t\t\t\t<Card padding={4} radius={2} tone={state.disabled ? 'transparent' : 'critical'} border>\n\t\t\t\t\t<Stack space={3}>\n\t\t\t\t\t\t{state.disabled && <Text size={1} weight=\"medium\">Switched off</Text>}\n\t\t\t\t\t\t<Text size={1} muted={state.disabled}>{state.message}</Text>\n\t\t\t\t\t\t{!state.disabled && (\n\t\t\t\t\t\t\t<Box>\n\t\t\t\t\t\t\t\t{/* A plain button. The shim's DOM fallback drops `text`, `mode` and `fontSize`, so\n\t\t\t\t\t\t\t\t this rendered as an unlabelled zero-size control — on the one screen\n\t\t\t\t\t\t\t\t where the reader most needs a working one. Every other control in this\n\t\t\t\t\t\t\t\t file already avoids Button for that reason; the error state did not\n\t\t\t\t\t\t\t\t get the lesson. */}\n\t\t\t\t\t\t\t\t<button type=\"button\" style={rangeButton(false)} onClick={reload}>Try again</button>\n\t\t\t\t\t\t\t</Box>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Stack>\n\t\t\t\t</Card>\n\t\t\t)}\n\n\t\t\t{state.status === 'ready' && (\n\t\t\t\t<ReadyReport\n\t\t\t\t\tenvelope={state.envelope}\n\t\t\t\t\ttabId={tabId}\n\t\t\t\t\tapiBaseUrl={apiBaseUrl}\n\t\t\t\t\trange={range}\n\t\t\t\t\tcustom={custom}\n\t\t\t\t\trevalidationError={state.revalidationError ?? null}\n\t\t\t\t\tdiagnostics={diagnostics}\n\t\t\t\t\tonBrush={onBrush}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/**\n * An ISO date N days before today, in the viewer's local zone.\n *\n * @param days - how many days back; 0 is today\n */\nfunction isoDaysAgo(days: number): string {\n\t// Stepped in UTC throughout. It previously shifted the date in LOCAL time and then serialised\n\t// with toISOString(), which is UTC — so anyone east of UTC got a default range one day short.\n\tconst now = new Date()\n\tconst utcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())\n\treturn new Date(utcMidnight - days * 86_400_000).toISOString().slice(0, 10)\n}\n\n/**\n * Reports that draw a period-over-period delta. Must match the server's own list, which decides\n * which reports pay for a second window.\n */\nconst COMPARED_REPORTS: ReportName[] = ['acquisition', 'measurement-health']\n\n/** Tabs that actually draw a delta. Not the same as the reports that fetch a comparison window. */\nconst COMPARED_TABS = ['overview', 'acquisition']\n\n/**\n * Catches a render error in one panel so it does not blank the whole tool.\n *\n * There was no boundary anywhere. An uncaught throw unmounts the entire React tree, so a single bad\n * field reference took out the tabs and the range selector along with the panel — which is what\n * happened twice, and both times the reader lost four working panels to fix one.\n */\nclass PanelBoundary extends React.Component<\n\t{ children: React.ReactNode; resetKey: string },\n\t{ error: Error | null; shownFor: string }\n> {\n\tconstructor(props: { children: React.ReactNode; resetKey: string }) {\n\t\tsuper(props)\n\t\tthis.state = { error: null, shownFor: props.resetKey }\n\t}\n\n\tstatic getDerivedStateFromError(error: Error) {\n\t\treturn { error }\n\t}\n\n\t/**\n\t * Clear a caught error when the tab changes, WITHOUT remounting the subtree.\n\t *\n\t * This boundary used to be keyed on the active tab, which cleared the error by throwing the\n\t * whole subtree away — and with it every table's sort, filter and exclusions, and `useReport`'s\n\t * state, which resets to `idle` and refetches. So the natural exploratory loop — sort\n\t * Acquisition by engagement, check Journey, come back — always cost the reader their work, on a\n\t * component that invests real care in preserving exactly that state across a RANGE change.\n\t *\n\t * A reset key does the one thing the mount key was there for and nothing else.\n\t */\n\tstatic getDerivedStateFromProps(\n\t\tprops: { resetKey: string },\n\t\tstate: { error: Error | null; shownFor: string },\n\t) {\n\t\tif (props.resetKey !== state.shownFor) return { error: null, shownFor: props.resetKey }\n\t\treturn null\n\t}\n\n\tcomponentDidCatch(error: Error) {\n\t\tconsole.error('Visitor insights: panel render failed:', error.message)\n\t}\n\n\trender() {\n\t\tif (!this.state.error) return this.props.children\n\t\treturn (\n\t\t\t<Card padding={4} radius={2} tone=\"critical\" border>\n\t\t\t\t<Stack space={3}>\n\t\t\t\t\t<Text size={1} weight=\"medium\">This panel could not be drawn</Text>\n\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\tThe other panels are unaffected. This usually means the site’s API route is a\n\t\t\t\t\t\tdifferent version from this Studio — redeploying the site normally clears it.\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text size={0} muted>{this.state.error.message}</Text>\n\t\t\t\t\t{/* No retry button. The crash class this catches is a version-skew field access,\n\t\t\t\t\t which is deterministic: clearing the error re-renders the identical subtree\n\t\t\t\t\t against the identical cached envelope and throws again immediately. The\n\t\t\t\t\t previous button called setActivePanel with the value it already had, so React\n\t\t\t\t\t bailed out and nothing refetched — it did nothing twice over. Switching tabs\n\t\t\t\t\t resets the boundary, which is the honest escape. */}\n\t\t\t\t\t<Text size={0} muted>Switch tabs and back, or redeploy the site, to clear this.</Text>\n\t\t\t\t</Stack>\n\t\t\t</Card>\n\t\t)\n\t}\n}\n\n/** The tool itself. */\nexport { PanelBoundary }\n\nexport function VisitorInsightsTool(props: VisitorInsightsToolComponentProps): React.ReactElement {\n\t// Nested options win, since that is the shape the Studio supplies; the flat props are the\n\t// direct-use fallback. See VisitorInsightsToolComponentProps for why both exist.\n\tconst apiBaseUrl = props.tool?.options?.apiBaseUrl ?? props.apiBaseUrl ?? ''\n\t// Kept for the document title and accessible naming, no longer printed as a subtitle.\n\tconst siteLabel = props.tool?.options?.siteLabel ?? props.siteLabel ?? ''\n\n\t/*\n\t * The view a link asked for, read once at mount.\n\t *\n\t * Read lazily inside useState so it happens on the first render rather than after it — seeding\n\t * from a default and then correcting in an effect would fire a fetch for the week, throw it\n\t * away, and fetch again for the window the link actually named.\n\t */\n\tconst [linked] = useState<ViewState>(() => (typeof window === 'undefined' ? {} : decodeView(window.location.hash)))\n\t// Validated against the tabs and ranges that exist. A link naming something this build does not\n\t// have falls back rather than selecting nothing.\n\tconst linkedRange = RANGES.some((r) => r.key === linked.range) ? (linked.range as RangeKey) : null\n\tconst linkedTab = PANELS.some((p) => p.id === linked.tab) ? (linked.tab as string) : null\n\n\t/*\n\t * Month, not week.\n\t *\n\t * A week is seven days, and the coverage-incident detector needs twenty-one measured days before\n\t * it will name a date — so on the range the tool opened with, the founding failure it was built\n\t * to surface could not be reported at all. At seven orders a quarter a week also holds nought or\n\t * one order, so the headline read \"Revenue £0\" and the cards showed changes of 100% or \"new\".\n\t * The default view was the one view with nothing to say.\n\t */\n\tconst [range, setRange] = useState<RangeKey>(linkedRange ?? 'month')\n\t// The range in force before a brush, so the escape hatch returns where the reader was rather\n\t// than to a week they never chose. Brushing from Quarter and landing in Week loses 358 days,\n\t// and the label said so — a stated wrong answer rather than a bug you could rationalise.\n\tconst [rangeBeforeBrush, setRangeBeforeBrush] = useState<RangeKey>(linkedRange ?? 'month')\n\t// Seeded with the trailing month so the picker opens on a valid range rather than on two empty\n\t// fields. Local dates, not the property's: this is only the form's starting value, and the\n\t// server re-resolves whatever is submitted against the property timezone.\n\tconst [custom, setCustom] = useState(() => (linked.from && linked.to\n\t\t? { start: linked.from, end: linked.to }\n\t\t: { start: isoDaysAgo(30), end: isoDaysAgo(0) }))\n\t/** Whether the custom window already runs to today, so forward panning has nowhere to go. */\n\tconst atPresent = custom.end >= isoDaysAgo(0)\n\t/**\n\t * What the deltas are measured against.\n\t *\n\t * Type sales are seasonal, so \"the previous thirty days\" is often the least informative baseline\n\t * available — and it was the only one. Both cost the same: one comparison window either way.\n\t */\n\tconst [compare, setCompare] = useState<'previous-period' | 'same-period-last-year'>('previous-period')\n\n\tconst [activePanel, setActivePanel] = useState<string>(linkedTab ?? 'overview')\n\n\t// And write it back, so the address bar always describes what is on screen.\n\t//\n\t// replaceState, not pushState: Sanity owns the history stack for its own navigation, and adding\n\t// an entry per range click would make the Studio's back button walk through this tool's\n\t// filters instead of leaving the tool. The link is still copyable at any moment, which is the\n\t// thing that was missing.\n\tReact.useEffect(() => {\n\t\tif (typeof window === 'undefined') return\n\t\tconst next = mergeIntoHash(window.location.hash, {\n\t\t\ttab: activePanel,\n\t\t\trange,\n\t\t\tfrom: custom.start,\n\t\t\tto: custom.end,\n\t\t})\n\t\tif (next !== window.location.hash) {\n\t\t\twindow.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${next}`)\n\t\t}\n\t}, [activePanel, range, custom.start, custom.end])\n\n\tconst active = PANELS.find((p) => p.id === activePanel) ?? PANELS[0]\n\n\treturn (\n\t\t// Named for assistive technology even though the name is not printed: a Studio user with\n\t\t// several tabs open hears which site's figures these are, without the heading repeating\n\t\t// what the Studio navigation already says.\n\t\t<Container width={4} padding={4} as=\"section\" aria-label={siteLabel ? `Visitor insights for ${siteLabel}` : 'Visitor insights'}>\n\t\t\t{/* Defines --vi-comparison once for the whole tool. It carries a per-theme pair where the\n\t\t\t browser supports light-dark(), and every use site carries the balanced colour as a\n\t\t\t var() fallback, so this element failing to render degrades rather than breaks. */}\n\t\t\t<style>{COMPARISON_STYLE}</style>\n\t\t\t<Stack space={5}>\n\t\t\t\t<Flex align=\"flex-start\" justify=\"space-between\" gap={4} wrap=\"wrap\">\n\t\t\t\t\t{/* No site name. The tool is mounted inside that site's own Studio, which already\n\t\t\t\t\t says whose it is in the navigation — repeating it was a line of chrome\n\t\t\t\t\t where the range and its dates are the useful context. */}\n\t\t\t\t\t<Heading size={2}>Visitor insights</Heading>\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<RangeSelector value={range} custom={custom} onChange={setRange} onCustomChange={setCustom} />\n\t\t\t\t\t\t{/* Only where a delta is actually drawn.\n\t\t\t\t\t\t\n\t\t\t\t\t\t It rendered on all five tabs and visibly changed nothing on three of them,\n\t\t\t\t\t\t while still re-keying the fetch cache — a live control in the masthead\n\t\t\t\t\t\t that does nothing is worse than no control, because the reader concludes\n\t\t\t\t\t\t the tool is unresponsive rather than that the setting is irrelevant here. */}\n\t\t\t\t\t\t{COMPARED_TABS.includes(activePanel) && <BasisSelector value={compare} onChange={setCompare} />}\n\t\t\t\t\t</Stack>\n\t\t\t\t</Flex>\n\n\t\t\t\t<PanelTabs value={activePanel} onChange={setActivePanel} />\n\n\t\t\t\t{/* The constraint, stated where it applies, on every panel.\n\t\t\t\t Once a span can be dragged, a reader is regularly in a window nobody chose from\n\t\t\t\t a menu — and the only indication of it was a footer line below all the content. */}\n\t\t\t\t{range === 'custom' && (\n\t\t\t\t\t<div style={contextRow}>\n\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t{/* Not always \"narrowed\": a custom range can be wider than a week, up to the\n\t\t\t\t\t\t\t two-year cap. */}\n\t\t\t\t\t\t\tShowing {custom.start} to {custom.end} ({daysBetween(custom.start, custom.end)} days).\n\t\t\t\t\t\t\t{daysBetween(custom.start, custom.end) < 14 && (\n\t\t\t\t\t\t\t\t<> Short windows lose GA4’s low-count rows, so the source and typeface\n\t\t\t\t\t\t\t\tlists will be shorter than reality.</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t{/* Stepping, because a brush only ever goes inward. Every selection is drawn on\n\t\t\t\t\t\t the current window, so successive brushes narrow and narrow again with no\n\t\t\t\t\t\t way to widen, pan, or ask the obvious follow-up — \"was the week before\n\t\t\t\t\t\t like this?\" The span shifts by its own length, which is the same window\n\t\t\t\t\t\t the server already computes for the comparison. */}\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tstyle={inlineClear}\n\t\t\t\t\t\t\taria-label=\"Shift the window back by its own length\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tconst span = daysBetween(custom.start, custom.end)\n\t\t\t\t\t\t\t\tsetCustom({ start: shiftDays(custom.start, -span), end: shiftDays(custom.end, -span) })\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t← earlier\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{/* Disabled at the present, rather than allowed to overshoot it. The server\n\t\t\t\t\t\t rejects a future end date with a 400, which the panel renders as a critical\n\t\t\t\t\t\t card IN PLACE OF ALL CONTENT — including the timeline you would use to get\n\t\t\t\t\t\t back. A navigation control should not be able to put the tool into an error\n\t\t\t\t\t\t state the server already knows is unreachable. */}\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\taria-label=\"Shift the window forward by its own length\"\n\t\t\t\t\t\t\tdisabled={atPresent}\n\t\t\t\t\t\t\t// `inlineClear` hard-sets colour, opacity and cursor, so the UA's disabled\n\t\t\t\t\t\t\t// greying never applied: a disabled control was pixel-identical to the live\n\t\t\t\t\t\t\t// one beside it and its only explanation was a title, which disabled elements\n\t\t\t\t\t\t\t// suppress in several browsers and which does not exist on touch.\n\t\t\t\t\t\t\tstyle={atPresent ? { ...inlineClear, opacity: 0.35, cursor: 'not-allowed' } : inlineClear}\n\t\t\t\t\t\t\ttitle={atPresent ? 'This window already ends today' : undefined}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tconst span = daysBetween(custom.start, custom.end)\n\t\t\t\t\t\t\t\tconst today = isoDaysAgo(0)\n\t\t\t\t\t\t\t\t// `daysBetween` is INCLUSIVE — it adds one — so it is the wrong function\n\t\t\t\t\t\t\t\t// for a delta. Using it for the remaining room shifted the window one day\n\t\t\t\t\t\t\t\t// PAST today, producing exactly the future-dated 400 this clamp exists to\n\t\t\t\t\t\t\t\t// prevent, and made the `<= 0` guard unreachable.\n\t\t\t\t\t\t\t\tconst room = daysBetween(custom.end, today) - 1\n\t\t\t\t\t\t\t\tconst allowed = Math.min(span, room)\n\t\t\t\t\t\t\t\tif (allowed <= 0) return\n\t\t\t\t\t\t\t\tsetCustom({ start: shiftDays(custom.start, allowed), end: shiftDays(custom.end, allowed) })\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tlater →\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button type=\"button\" style={inlineClear} onClick={() => setRange(rangeBeforeBrush)}>\n\t\t\t\t\t\t\tBack to the {RANGES.find((r) => r.key === rangeBeforeBrush)?.label.toLowerCase() ?? 'week'}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t<Box\n\t\t\t\t\trole=\"tabpanel\"\n\t\t\t\t\tid={`panel-${activePanel}`}\n\t\t\t\t\taria-labelledby={`tab-${activePanel}`}\n\t\t\t\t\t// Focusable so a keyboard user can Tab straight from the tab into its content.\n\t\t\t\t\ttabIndex={0}\n\t\t\t\t\tstyle={{ position: 'relative' }}\n\t\t\t\t>\n\t\t\t\t\t<Stack space={4}>\n\t\t\t\t\t\t{/* Keyed on the tab, so a throw on one panel does not render the error card for\n\t\t\t\t\t\t every other. Without the key the boundary held `error` forever and its own\n\t\t\t\t\t\t copy — \"The other panels are unaffected\" — became false. */}\n\t\t\t\t\t\t<PanelBoundary resetKey={`${activePanel}|${range}|${custom.start}|${custom.end}`}>\n\t\t\t\t\t\t\t<ReportPanel\n\t\t\t\t\t\t\t\ttabId={active?.id ?? 'overview'}\n\t\t\t\t\t\t\t\treport={active?.report ?? 'measurement-health'}\n\t\t\t\t\t\t\t\tapiBaseUrl={apiBaseUrl}\n\t\t\t\t\t\t\t\trange={range}\n\t\t\t\t\t\t\t\tcustom={custom}\n\t\t\t\t\t\t\t\tcompare={compare}\n\t\t\t\t\t\t\t\tonBrush={(start, end) => { if (range !== 'custom') setRangeBeforeBrush(range); setCustom({ start, end }); setRange('custom') }}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</PanelBoundary>\n\t\t\t\t\t</Stack>\n\t\t\t\t</Box>\n\t\t\t</Stack>\n\t\t</Container>\n\t)\n}\n","/**\n * Data hook for the Studio panels.\n *\n * Forwards the Studio's own Sanity session token to the site's API route, which verifies it against\n * Sanity. There is no shared secret: the Studio bundle is public, so anything compiled into it is\n * extractable.\n */\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { useClient } from 'sanity'\nimport type { RangeKey, ReportEnvelope, ReportName } from '../types'\n\n/**\n * Envelopes already fetched this session, keyed by what identifies them.\n *\n * Module-level and deliberately unbounded within a session: there are five reports and a handful of\n * ranges, so the ceiling is tens of entries. Without it every tab visit was a fresh round trip —\n * Acquisition to Journey and back paid twice and waited twice — and the server's own cache is\n * per-instance with a five-minute TTL, so a cold serverless instance re-fanned-out to GA4, Vercel,\n * Sanity and Mailchimp to answer a question it had answered a moment earlier.\n *\n * Served immediately, then revalidated behind, so a return trip is a view change rather than a\n * page load. That is most of what makes five tabs feel like one dataset.\n */\nconst envelopeCache = new Map<string, ReportEnvelope<unknown>>()\n\n/**\n * The last GA4 shortfall seen for a given site and window, so every tab can know it.\n *\n * `shortfallRatio` is computed by one report. The other three panels are built entirely from GA4\n * and had no access to it at any position on the page, so the tool could print \"GA4 is seeing 20%\n * of your traffic — treat its figures as broken\" on one tab and then render `Sessions 357` in the\n * largest available type on the next, with nothing anywhere saying it was a fifth of reality. A\n * source that answers confidently and wrongly is `status: 'ok'`, so the source row cannot catch it.\n *\n * Keyed by site and window because the shortfall is a property of both — carrying last week's\n * figure onto a quarter view would be a different lie.\n */\nconst shortfallByWindow = new Map<string, number>()\n\n/** The window part of a cache key: the named range, or the custom bounds. */\nfunction windowKey(range: string, custom?: { start: string; end: string }): string {\n\treturn range === 'custom' && custom ? `${custom.start}..${custom.end}` : range\n}\n\n/**\n * What is known about GA4's coverage of this site and window, or null when nothing is.\n *\n * Null is a real answer and must render as nothing: a panel that cannot say how lossy its source is\n * should not imply the source is fine.\n *\n * @param base - the site's API base URL, as passed to the tool\n * @param range - the range name currently selected\n * @param custom - the custom bounds, when the range is custom\n */\nexport function knownShortfall(base: string, range: string, custom?: { start: string; end: string }): number | null {\n\treturn shortfallByWindow.get(`${base}|${windowKey(range, custom)}`) ?? null\n}\n\n/**\n * Record a shortfall an envelope carried, if it carried one.\n *\n * Exported for its tests, not for consumers — deliberately absent from `src/index.ts`, so it is a\n * module seam rather than public API.\n */\nexport function rememberShortfall(base: string, range: string, custom: { start: string; end: string } | undefined, data: unknown): void {\n\tconst ratio = (data as { shortfallRatio?: unknown } | null)?.shortfallRatio\n\t// Bounded on both sides: a ratio outside 0..1 is a bug upstream, not a coverage figure.\n\tif (typeof ratio === 'number' && Number.isFinite(ratio) && ratio >= 0 && ratio <= 1) {\n\t\tshortfallByWindow.set(`${base}|${windowKey(range, custom)}`, ratio)\n\t}\n}\n\n/** Identity of a request: everything that changes the answer. */\nfunction cacheKey(base: string, report: string, range: string, custom?: { start: string; end: string }, compare?: string): string {\n\t// apiBaseUrl included: the plugin takes a `name` so it can be registered twice in one Studio,\n\t// and without the base those two registrations would serve each other's envelopes.\n\t// The comparison basis is part of the key: two bases produce different envelopes for the same\n\t// window, and without it switching baseline would serve the other one's deltas from cache.\n\treturn [base, report, range, custom?.start ?? '', custom?.end ?? '', compare ?? 'previous-period'].join('|')\n}\n\n/**\n * Whether a previous answer may stay on screen while the next one loads.\n *\n * Only for the same report. Held across a RANGE change, which is the point — a reader must be able\n * to change one variable without losing their sort, their filter and their place. NOT held across\n * a REPORT change: that handed one panel another's payload, which does not crash, because every\n * field is guarded with `?? []`, and therefore silently drew an empty funnel. Wrong content is\n * worse than a spinner, and unlike a spinner it looks like an answer.\n *\n * Exported as a pure predicate so it can be tested without a DOM, which this package does not have.\n *\n * @param current - the state on screen right now\n * @param nextReport - the report about to be fetched\n */\nexport function holdsPreviousAnswer<T>(current: ReportState<T>, nextReport: ReportName): boolean {\n\treturn current.status === 'ready' && current.envelope.report === nextReport\n}\n\n/** Sanity API version this tool pins. Fixed rather than \"latest\" so behaviour cannot drift. */\nconst API_VERSION = '2024-03-01'\n\n/** Loading state for a report. */\nexport type ReportState<T> =\n\t| { status: 'idle' }\n\t| { status: 'loading' }\n\t| { status: 'ready'; envelope: ReportEnvelope<T>; stale?: boolean; revalidationError?: string }\n\t// `disabled` separates \"this site has visitor insights switched off\" from \"something broke\".\n\t// They are both non-200s, but only one of them is worth a retry button.\n\t| { status: 'error'; message: string; disabled?: boolean }\n\n/** Options for useReport. */\nexport interface UseReportOptions {\n\t/** Explicit dates, required when `range` is 'custom' and ignored otherwise. */\n\tcustom?: { start: string; end: string }\n\t/** Base URL of the site serving the reports, e.g. `https://dardenstudio.com`. */\n\tapiBaseUrl: string\n\treport: ReportName\n\trange: RangeKey\n\t/** Which baseline the deltas are measured against. Defaults to the preceding window. */\n\tcompare?: 'previous-period' | 'same-period-last-year'\n\t/**\n\t * Whether to fetch at all. Defaults to true.\n\t *\n\t * Lets a panel declare a secondary report it only needs on one tab, without the other four\n\t * paying a request for data they will not draw.\n\t */\n\tenabled?: boolean\n}\n\n/**\n * Fetch a report, re-fetching when the range changes.\n *\n * @returns the current state plus a `reload` for manual refresh\n */\nexport function useReport<T>({ apiBaseUrl, report, range, custom, compare, enabled = true }: UseReportOptions): {\n\tstate: ReportState<T>\n\treload: () => void\n} {\n\tconst client = useClient({ apiVersion: API_VERSION })\n\tconst [state, setState] = useState<ReportState<T>>({ status: 'idle' })\n\tconst [nonce, setNonce] = useState(0)\n\n\t// Lets an in-flight response from a previous range be discarded rather than overwriting a newer one.\n\tconst requestIdRef = useRef(0)\n\n\tconst reload = useCallback(() => setNonce((n) => n + 1), [])\n\n\tuseEffect(() => {\n\t\tif (!enabled) return\n\t\tconst requestId = requestIdRef.current + 1\n\t\trequestIdRef.current = requestId\n\n\t\tconst controller = new AbortController()\n\t\tconst key = cacheKey(apiBaseUrl, report, range, custom, compare)\n\t\tconst cached = envelopeCache.get(key)\n\n\t\t// A cached answer for THIS key is shown at once and still revalidated. Marked stale so the\n\t\t// reader knows a fresher one is coming, rather than being told nothing and wondering.\n\t\tif (cached) setState({ status: 'ready', envelope: cached as ReportEnvelope<T>, stale: true })\n\n\t\t// Keep showing the last answer while fetching the next one, marked stale.\n\t\t//\n\t\t// This used to clear to `loading` unconditionally, which unmounted the ready subtree and\n\t\t// took every table's sort, filter and exclusions with it. Widen Week to Month to check\n\t\t// whether a ranking holds and the ranking is gone, after the screen went blank — so a reader\n\t\t// could never hold a question steady while changing one variable, which is the whole of\n\t\t// what \"explorable\" means. It also dumped keyboard focus to the body on every range change.\n\t\t// Only a previous answer FOR THE SAME REPORT may be held over. Without that check, switching\n\t\t// tabs with nothing cached kept the old envelope and handed it to the new panel — Journey\n\t\t// rendered with Acquisition's payload, which does not crash (every field is guarded with\n\t\t// `?? []`) and therefore silently drew an empty funnel instead. Wrong content is worse than\n\t\t// a spinner, and unlike a spinner it looks like an answer.\n\t\telse {\n\t\t\tsetState((current) => (\n\t\t\t\tholdsPreviousAnswer(current, report) ? { ...current, stale: true } : { status: 'loading' }\n\t\t\t))\n\t\t}\n\n\t\tasync function run() {\n\t\t\t// The Studio client carries the session token under token-based auth. Under cookie-based\n\t\t\t// auth it does not, and there is no way to forward credentials the browser will not\n\t\t\t// expose — so say so plainly rather than failing with an opaque 401.\n\t\t\tconst token = client.config().token\n\t\t\tif (!token) {\n\t\t\t\tsetState({\n\t\t\t\t\tstatus: 'error',\n\t\t\t\t\tmessage: 'No Sanity session token available in this Studio. Visitor Insights needs token-based auth to call the site API.',\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\t// Coerced rather than assumed. An empty base is legitimate — it means the Studio is\n\t\t\t\t// served from the same origin as the site — but an undefined one used to reach\n\t\t\t\t// .replace() directly and crash every panel with a stack trace instead of a\n\t\t\t\t// message. Treating a missing base as same-origin degrades to the common case.\n\t\t\t\tconst base = typeof apiBaseUrl === 'string' ? apiBaseUrl.replace(/\\/$/, '') : ''\n\t\t\t\t// Custom ranges carry their own dates. Built with URLSearchParams so a date can\n\t\t\t\t// never break the query string, and so the two named-range and custom-range paths\n\t\t\t\t// produce one shape rather than two.\n\t\t\t\tconst query = new URLSearchParams({ range })\n\t\t\t\t// Only sent when it differs from the default, so an unchanged request keeps the exact\n\t\t\t\t// URL — and therefore the exact cache key — it had before this option existed.\n\t\t\t\tif (compare && compare !== 'previous-period') query.set('compare', compare)\n\t\t\t\tif (range === 'custom' && custom) {\n\t\t\t\t\tquery.set('start', custom.start)\n\t\t\t\t\tquery.set('end', custom.end)\n\t\t\t\t}\n\t\t\t\tconst url = `${base}/api/visitor-insights/${report}?${query.toString()}`\n\t\t\t\tconst response = await fetch(url, {\n\t\t\t\t\theaders: { Authorization: `Bearer ${token}` },\n\t\t\t\t\tsignal: controller.signal,\n\t\t\t\t})\n\n\t\t\t\tif (requestIdRef.current !== requestId) return\n\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\t// A 400 is the handler rejecting the request itself — an inverted or oversized\n\t\t\t\t\t// custom range, most often — and a 503 is the site's master switch being off.\n\t\t\t\t\t// Both carry a message written for the reader. Anything else may echo upstream\n\t\t\t\t\t// detail, so only the status crosses over.\n\t\t\t\t\tlet detail = response.status === 401\n\t\t\t\t\t\t? 'Not authorised — sign in to the Studio again.'\n\t\t\t\t\t\t: `Request failed (${response.status})`\n\t\t\t\t\tlet disabled = false\n\t\t\t\t\tif (response.status === 400 || response.status === 503) {\n\t\t\t\t\t\tconst body = (await response.json().catch(() => null)) as { error?: string; disabled?: boolean } | null\n\t\t\t\t\t\tif (body?.error) detail = body.error\n\t\t\t\t\t\tdisabled = body?.disabled === true\n\t\t\t\t\t}\n\t\t\t\t\tif (requestIdRef.current !== requestId) return\n\t\t\t\t\tsetState({ status: 'error', message: detail, disabled })\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tconst envelope = (await response.json()) as ReportEnvelope<T>\n\t\t\t\tif (requestIdRef.current !== requestId) return\n\n\t\t\t\tenvelopeCache.set(key, envelope as ReportEnvelope<unknown>)\n\t\t\t\trememberShortfall(apiBaseUrl, range, custom, envelope.data)\n\t\t\t\tsetState({ status: 'ready', envelope })\n\t\t\t} catch (e) {\n\t\t\t\tif (controller.signal.aborted || requestIdRef.current !== requestId) return\n\t\t\t\t// A failed REVALIDATION must not destroy the figures already on screen. It used to\n\t\t\t\t// replace a readable panel with a red card, and \"Try again\" flashed the data back\n\t\t\t\t// and then the error again. The last good answer stays; the failure is reported\n\t\t\t\t// beside it.\n\t\t\t\tsetState((current) => (\n\t\t\t\t\tcurrent.status === 'ready'\n\t\t\t\t\t\t? { ...current, stale: false, revalidationError: (e as Error).message }\n\t\t\t\t\t\t: { status: 'error', message: (e as Error).message }\n\t\t\t\t))\n\t\t\t}\n\t\t}\n\n\t\tvoid run()\n\t\treturn () => controller.abort()\n\t// custom.start/end by value rather than the object: the caller builds a fresh object on every\n\t// render, and depending on its identity would refetch on each keystroke in the date fields.\n\t//\n\t// `compare` belongs here and was missing. The effect READS it — it goes into the query string and\n\t// into the cache key — so leaving it out meant choosing \"Last year\" selected the button and\n\t// fetched nothing: every delta on screen stayed measured against the previous period until some\n\t// other dependency happened to change. Not an inert control but a lying one, and the whole point\n\t// of giving the basis toggle its own colour was to tell the reader which window they were\n\t// looking at.\n\t}, [client, apiBaseUrl, report, range, custom?.start, custom?.end, compare, nonce, enabled])\n\n\treturn { state, reload }\n}\n\n/** Drop every remembered shortfall. For tests, so one case cannot leak into the next. */\nexport function forgetShortfalls(): void {\n\tshortfallByWindow.clear()\n}\n","/**\n * The tool's view, in the URL — so a finding can be sent to someone.\n *\n * Every piece of state here was component-local: tab, range, and the custom window. A Studio\n * reload, a browser back, or an accidental navigation discarded the whole investigation, and\n * \"Acquisition, 24 August to 7 September\" — the exact view in which someone notices the thing worth\n * noticing — could not be handed to a colleague or returned to by the person who found it. There is\n * a \"Copy as CSV\" in this tool precisely because sharing a finding is the real task; the view\n * itself was the one thing that could not be shared.\n *\n * The HASH, not the path or the query. Sanity's own router owns the path, and writing to it would\n * fight the Studio for control of navigation; the hash is unclaimed, survives a reload, is carried\n * by a copied link, and is ignored by every server. The value is namespaced because other tools in\n * the same Studio may want the same trick.\n *\n * Everything here is a pure string transformation, so it is testable without a DOM — which matters,\n * because the alternative is a feature whose only proof is clicking around a deployed Studio.\n */\n\n/** The part of the tool's state worth putting in a link. */\nexport interface ViewState {\n\t/** Tab id, e.g. `overview`. */\n\ttab?: string\n\t/** Range key, e.g. `week` or `custom`. */\n\trange?: string\n\t/** Custom window start, ISO date. Only meaningful when `range` is `custom`. */\n\tfrom?: string\n\t/** Custom window end, ISO date. */\n\tto?: string\n}\n\n/** Our namespace within the hash, so another tool's state is left alone. */\nconst KEY = 'insights'\n\n/** An ISO calendar date, and nothing else — this value reaches a report request. */\nconst ISO_DATE = /^\\d{4}-\\d{2}-\\d{2}$/\n\n/**\n * Identifiers we will echo back into the URL.\n *\n * Tab and range ids are matched against known lists by the caller, but this is the first gate: the\n * hash is attacker-controllable in the sense that anyone can send a link, so nothing shaped\n * unexpectedly gets as far as being compared, stored, or rendered.\n */\nconst IDENTIFIER = /^[a-z][a-z0-9-]{0,31}$/\n\n/**\n * Serialise a view to the hash fragment it should occupy.\n *\n * Returns an empty string for an empty view, so a default view leaves the URL clean rather than\n * decorating it with state nobody set.\n *\n * @param view - the state to encode\n */\nexport function encodeView(view: ViewState): string {\n\tconst parts: string[] = []\n\tif (view.tab && IDENTIFIER.test(view.tab)) parts.push(`tab:${view.tab}`)\n\tif (view.range && IDENTIFIER.test(view.range)) parts.push(`range:${view.range}`)\n\t// The window is only carried when it is the one in use. A stale custom range riding along in\n\t// every link would reopen someone else's window when they clicked a link about the week.\n\tif (view.range === 'custom') {\n\t\tif (view.from && ISO_DATE.test(view.from)) parts.push(`from:${view.from}`)\n\t\tif (view.to && ISO_DATE.test(view.to)) parts.push(`to:${view.to}`)\n\t}\n\treturn parts.length > 0 ? `${KEY}=${parts.join(';')}` : ''\n}\n\n/**\n * Read a view out of a hash fragment.\n *\n * Every field is validated and an unrecognised one is dropped rather than defaulted, so a truncated\n * or hand-edited link degrades to whatever it could parse instead of failing or, worse, silently\n * requesting a window nobody chose.\n *\n * @param hash - `window.location.hash`, with or without its leading `#`\n */\nexport function decodeView(hash: string): ViewState {\n\tconst raw = hash.startsWith('#') ? hash.slice(1) : hash\n\t// Other tools' fragments live alongside ours, separated by `&` as in a query string.\n\tconst mine = raw.split('&').find((part) => part.startsWith(`${KEY}=`))\n\tif (!mine) return {}\n\n\tconst view: ViewState = {}\n\tfor (const pair of mine.slice(KEY.length + 1).split(';')) {\n\t\tconst separator = pair.indexOf(':')\n\t\tif (separator < 1) continue\n\t\tconst name = pair.slice(0, separator)\n\t\tconst value = pair.slice(separator + 1)\n\t\tif (name === 'tab' && IDENTIFIER.test(value)) view.tab = value\n\t\telse if (name === 'range' && IDENTIFIER.test(value)) view.range = value\n\t\telse if (name === 'from' && ISO_DATE.test(value)) view.from = value\n\t\telse if (name === 'to' && ISO_DATE.test(value)) view.to = value\n\t}\n\n\t// A custom range needs both ends. One alone would resolve against a default the sender never\n\t// saw, which is a different window wearing the same link.\n\tif (view.range === 'custom' && !(view.from && view.to)) {\n\t\tdelete view.from\n\t\tdelete view.to\n\t\tdelete view.range\n\t}\n\n\treturn view\n}\n\n/**\n * Replace our fragment in a hash, preserving anyone else's.\n *\n * @param hash - the current hash\n * @param view - the state to write\n */\nexport function mergeIntoHash(hash: string, view: ViewState): string {\n\tconst raw = hash.startsWith('#') ? hash.slice(1) : hash\n\tconst others = raw.split('&').filter((part) => part.length > 0 && !part.startsWith(`${KEY}=`))\n\tconst mine = encodeView(view)\n\tconst all = mine ? [...others, mine] : others\n\treturn all.length > 0 ? `#${all.join('&')}` : ''\n}\n","/**\n * Date-range resolution.\n *\n * Every range is resolved against one declared timezone — the GA4 property's — because the three\n * sources bucket time differently: GA4 by the property timezone, Vercel by UTC, Sanity `_createdAt`\n * by UTC. Without a single anchor, an order placed near midnight lands in different weeks depending\n * on which source you ask, which corrupts exactly the cross-source comparison this tool exists for.\n */\n\nimport type { DateRange, RangeKey } from '../types'\n\n/** GA4 does not finalise the most recent days. Figures inside this window may still rise. */\nexport const GA4_PROCESSING_LAG_DAYS = 2\n\n/** Format a Date as `YYYY-MM-DD` in the given IANA timezone. */\nexport function formatInTimeZone(date: Date, timeZone: string): string {\n\t// en-CA gives ISO-ordered parts, which is what we want to reassemble.\n\tconst parts = new Intl.DateTimeFormat('en-CA', {\n\t\ttimeZone,\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t}).formatToParts(date)\n\n\tconst get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''\n\treturn `${get('year')}-${get('month')}-${get('day')}`\n}\n\n/**\n * How far the given instant's wall clock in `timeZone` sits from UTC, in milliseconds.\n *\n * @param date - the instant to measure at; the offset changes across DST boundaries\n */\nfunction zoneOffsetMs(date: Date, timeZone: string): number {\n\tconst parts = new Intl.DateTimeFormat('en-CA', {\n\t\ttimeZone,\n\t\thourCycle: 'h23',\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t\thour: '2-digit',\n\t\tminute: '2-digit',\n\t\tsecond: '2-digit',\n\t}).formatToParts(date)\n\n\tconst get = (type: string) => Number(parts.find((p) => p.type === type)?.value)\n\tconst asIfUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'))\n\treturn asIfUtc - date.getTime()\n}\n\n/**\n * The UTC instant at which a calendar day begins in a timezone.\n *\n * Orders are stored as UTC `_createdAt`, and every range in this tool is a set of calendar dates in\n * the GA4 property's timezone. Bounding an order query with a bare `${date}T00:00:00Z` therefore\n * asks for a different window than the one GA4 was asked for — seven or eight hours out for a\n * US-Pacific property. At a handful of orders a day that is a visible swing in a figure the panel\n * presents as exact, and it always moved orders in one direction, never both.\n *\n * @param isoDate - `YYYY-MM-DD` in `timeZone`\n * @param timeZone - IANA timezone\n * @returns an ISO 8601 instant in UTC\n */\nexport function zonedDayStartUtc(isoDate: string, timeZone: string): string {\n\tconst naive = Date.parse(`${isoDate}T00:00:00Z`)\n\tif (Number.isNaN(naive)) throw new Error(`Invalid ISO date: ${isoDate}`)\n\n\t// Two passes: the first guess can land on the wrong side of a DST transition, in which case its\n\t// offset is the wrong one to subtract. Re-measuring at the corrected instant settles it for\n\t// every zone whose transition is away from local midnight — which is all of them in practice,\n\t// including both foundry zones, and verified across spring-forward and fall-back and for\n\t// non-hour offsets (Kolkata +05:30, Kathmandu +05:45, Chatham +12:45/+13:45).\n\t//\n\t// It does NOT settle for a zone that transitions AT local midnight: the second pass measures\n\t// the offset at the transition instant itself and never converges, leaving the day boundary an\n\t// hour out in either direction (Santiago, Havana, Beirut, Cairo). Nothing double-counts,\n\t// because day N's end is day N+1's start by construction, so the window merely shifts by an\n\t// hour against the one GA4 was asked for. None of the three foundries uses such a zone; this is\n\t// recorded rather than fixed because a third pass does not converge either — it needs the\n\t// transition table, not another probe.\n\tlet instant = naive - zoneOffsetMs(new Date(naive), timeZone)\n\tinstant = naive - zoneOffsetMs(new Date(instant), timeZone)\n\treturn new Date(instant).toISOString()\n}\n\n/**\n * The UTC instant at which a calendar day ends in a timezone, exclusive.\n *\n * Exclusive rather than 23:59:59 so the last second of the day cannot be dropped, and so a day\n * that is 23 or 25 hours long across a DST change is still bounded correctly.\n *\n * @param isoDate - `YYYY-MM-DD` in `timeZone`\n * @param timeZone - IANA timezone\n */\nexport function zonedDayEndUtc(isoDate: string, timeZone: string): string {\n\treturn zonedDayStartUtc(shiftDays(isoDate, 1), timeZone)\n}\n\n/** Shift an ISO `YYYY-MM-DD` date by a whole number of days. */\nexport function shiftDays(isoDate: string, days: number): string {\n\tconst time = Date.parse(`${isoDate}T00:00:00Z`)\n\tif (Number.isNaN(time)) throw new Error(`Invalid ISO date: ${isoDate}`)\n\treturn new Date(time + days * 86_400_000).toISOString().slice(0, 10)\n}\n\n/** Inclusive whole days between two ISO dates. */\nexport function daysBetween(start: string, end: string): number {\n\treturn Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000) + 1\n}\n\n/** How many days each range key spans. */\n/**\n * What a period-over-period change is measured against.\n *\n * Type sales are seasonal — a release, a conference, a sale — so \"the previous thirty days\" is often\n * the least informative baseline available, and it was the only one. Both cost the same: one extra\n * comparison window either way.\n */\nexport type ComparisonBasis = 'previous-period' | 'same-period-last-year'\n\n/** Trailing window length in days, for the named ranges. `custom` carries its own dates. */\nexport const RANGE_DAYS: Record<Exclude<RangeKey, 'custom'>, number> = {\n\tweek: 7,\n\tmonth: 30,\n\tquarter: 91,\n\tyear: 365,\n}\n\n/**\n * Longest custom range accepted.\n *\n * Two years, because GA4 event-level retention tops out at 14 months on most properties and Vercel\n * Web Analytics at 12 to 24 depending on plan — beyond that both sources return silence that would\n * read as a collapse. The cap also bounds the cost of a single request.\n */\nexport const MAX_CUSTOM_RANGE_DAYS = 730\n\n/**\n * Resolve a range key into concrete dates, anchored to `timezone` and ending today.\n *\n * @param key - week, quarter or year\n * @param timezone - IANA timezone of the GA4 property this range will be queried against\n * @param now - injectable clock, so tests are deterministic\n */\nexport function resolveRange(key: Exclude<RangeKey, 'custom'>, timezone: string, now: Date = new Date()): DateRange {\n\tconst end = formatInTimeZone(now, timezone)\n\tconst start = shiftDays(end, -(RANGE_DAYS[key] - 1))\n\treturn { key, start, end, timezone }\n}\n\n/**\n * The equivalent range immediately before `range`, for period-over-period comparison.\n *\n * A bare count answers \"how many\", which on its own is not actionable; the comparison is what\n * tells someone whether to do anything.\n */\nexport function previousRange(range: DateRange, basis: ComparisonBasis = 'previous-period'): DateRange {\n\tif (basis === 'same-period-last-year') {\n\t\t/*\n\t\t * The same calendar window a year earlier.\n\t\t *\n\t\t * Shifted by 364 days rather than by a calendar year, so the window keeps its shape: a\n\t\t * Monday-to-Sunday week stays Monday-to-Sunday, and a foundry comparing a release week\n\t\t * against last year's is not comparing a weekend against a working week. It costs one day of\n\t\t * drift a year, which is worth far less than the weekday alignment.\n\t\t */\n\t\treturn {\n\t\t\tkey: range.key,\n\t\t\tstart: shiftDays(range.start, -364),\n\t\t\tend: shiftDays(range.end, -364),\n\t\t\ttimezone: range.timezone,\n\t\t}\n\t}\n\n\tconst span = daysBetween(range.start, range.end)\n\treturn {\n\t\tkey: range.key,\n\t\tstart: shiftDays(range.start, -span),\n\t\tend: shiftDays(range.end, -span),\n\t\ttimezone: range.timezone,\n\t}\n}\n\n/**\n * The trailing dates within a range whose GA4 figures are not yet settled.\n * Returns an empty array when the range ends before the lag window.\n */\nexport function provisionalDates(range: DateRange, now: Date = new Date()): string[] {\n\tconst today = formatInTimeZone(now, range.timezone)\n\tconst dates: string[] = []\n\n\tfor (let i = 0; i < GA4_PROCESSING_LAG_DAYS; i += 1) {\n\t\tconst date = shiftDays(today, -i)\n\t\tif (date >= range.start && date <= range.end) dates.push(date)\n\t}\n\n\treturn dates.sort()\n}\n\n/**\n * Notice text when a range includes days GA4 has not finished processing, or `null` when it does not.\n * Without this a Monday-morning glance at \"this week\" reads the trailing dip as a real drop.\n */\nexport function provisionalNotice(range: DateRange, now: Date = new Date()): string | null {\n\tconst dates = provisionalDates(range, now)\n\tif (dates.length === 0) return null\n\n\tconst from = dates[0]\n\treturn `GA4 has not finished processing ${from} onwards; those figures are provisional and will rise.`\n}\n\n/** Why a custom range was refused, in words a reader can act on. */\nexport type CustomRangeError = string\n\n/**\n * Build a range from explicit dates, or explain why they cannot be used.\n *\n * Validated here rather than at the handler because the same rules apply wherever a range comes\n * from, and because a bad range must never reach GA4 as a silently different query — an inverted\n * start and end returns an empty report, which renders as a site with no traffic.\n *\n * @param start - inclusive ISO date, YYYY-MM-DD\n * @param end - inclusive ISO date\n * @param timezone - IANA timezone of the property, so \"today\" means today there\n * @param now - injectable clock\n */\nexport function resolveCustomRange(\n\tstart: string,\n\tend: string,\n\ttimezone: string,\n\tnow: Date = new Date(),\n): { range: DateRange } | { error: CustomRangeError } {\n\tconst ISO = /^\\d{4}-\\d{2}-\\d{2}$/\n\tif (!ISO.test(start) || !ISO.test(end)) {\n\t\treturn { error: 'Dates must be written as YYYY-MM-DD.' }\n\t}\n\n\tconst startMs = Date.parse(`${start}T00:00:00Z`)\n\tconst endMs = Date.parse(`${end}T00:00:00Z`)\n\tif (!Number.isFinite(startMs) || !Number.isFinite(endMs)) {\n\t\treturn { error: 'That is not a real date.' }\n\t}\n\n\tif (endMs < startMs) {\n\t\treturn { error: 'The end date is before the start date.' }\n\t}\n\n\t// \"Today\" in the property's timezone, not the reader's. A Studio open in London asking a\n\t// New York property for today would otherwise be refused for half the day.\n\tconst today = formatInTimeZone(now, timezone)\n\tif (end > today) {\n\t\treturn { error: `The end date is in the future. The latest data available is ${today}.` }\n\t}\n\n\tconst days = Math.round((endMs - startMs) / 86400000) + 1\n\tif (days > MAX_CUSTOM_RANGE_DAYS) {\n\t\treturn { error: `Ranges are limited to ${MAX_CUSTOM_RANGE_DAYS} days. Both sources stop returning data well before that.` }\n\t}\n\n\treturn { range: { key: 'custom', start, end, timezone } }\n}\n\n/**\n * How many days after a send its orders are counted for.\n *\n * Three. A newsletter's clicks arrive within hours and its orders within a day or two; stretching\n * the window further does not attribute more sales to the send, it attributes more of the site's\n * ordinary trade to it. The window closes early when the next send lands, so overlapping sends\n * never claim the same order.\n */\nexport const SEND_WINDOW_DAYS = 3\n","/**\n * Shared renderers for metric values and comparison bars.\n *\n * Two rules are enforced here rather than left to each panel:\n *\n * 1. An unavailable metric renders as an em dash with a stated reason — never as \"0\". A missing\n * measurement and a measured zero mean opposite things, and must never look alike.\n * 2. Nothing is distinguished by colour alone. Bars carry a text value, a percentage and an\n * accessible label, so the figure survives greyscale, colour blindness and both Studio themes.\n */\n\nimport React, { useRef, useState } from 'react'\nimport { Badge, Box, Card, Flex, Heading, Stack, Text, Tooltip } from '@liiift-studio/sanity-ui-compat'\nimport { COMPARISON_TEXT, SERIES, mark } from './palette'\nimport type { MetricValue, UnavailableReason } from '../types'\nimport { valueOrNull } from '../types'\n\n/**\n * The four levels of vertical break, in pixels.\n *\n * Sanity's own scale is [0, 4, 8, 12, 20, 32, …] and this tool used one value from it — `space={3}`,\n * twelve pixels — for sixty-four per cent of all vertical spacing, with `space={4}` at twenty for\n * everything structural. So the largest break in the tool was 1.67 times an ordinary gap between\n * two lines inside a card, and a section boundary looked the same as the next line of a caption.\n * That is what \"under-designed\" meant here: not too little air, too EVEN air.\n *\n * Every step is at LEAST double the one below — 4, 8, 20, 40, so ×2, ×2.5, ×2. That is the property\n * that matters: a level has to be readable at a glance rather than measurable with a ruler, and\n * anything under about ×2 reads as the same level. Twenty rather than sixteen because a card gutter\n * wants the extra room and it is a real token; forty has no token at all — Sanity's scale jumps 32\n * to 52 — which is one more reason these are plain pixels.\n *\n * Plain pixels in `style` rather than `space` props for a second reason: the compat shim's DOM\n * fallback passes `style` through and drops `space`, `padding`, `tone`, `radius` and `border`\n * entirely. On the versions this package supports every primitive resolves, so that is a latent\n * fragility rather than a live fault — but four constants in this file already sidestep it for\n * exactly this reason, and the skeleton was the one part that never did.\n */\nexport const SPACE = { pair: 4, block: 8, group: 20, section: 40 } as const\n\n/**\n * A stack at each level.\n *\n * `display: grid` rather than a column flexbox: identical on one axis, but grid children do not\n * shrink, so a wide table cannot squash the block beside it. The convention is already in this file\n * — `funnelItem` and NoticeList's list both do it.\n */\nexport const stackPair: React.CSSProperties = { display: 'grid', gap: SPACE.pair }\n\n/** Label to figure to note, inside one card. */\nexport const stackBlock: React.CSSProperties = { display: 'grid', gap: SPACE.block }\n\n/** Card to card, check to check, a heading to its own body. */\nexport const stackGroup: React.CSSProperties = { display: 'grid', gap: SPACE.group }\n\n/** One titled region to the next. The only level above twenty. */\nexport const panelStack: React.CSSProperties = { display: 'grid', gap: SPACE.section }\n\n\n/** Human-readable explanation for each unavailable reason. */\nconst REASON_TEXT: Record<UnavailableReason, string> = {\n\tnot_instrumented: 'Not tracked on this site',\n\tbefore_cutover: 'Not tracked during this period',\n\t// \"Withheld ... for privacy\" reads as a legal hold on the reader's own data. What actually\n\t// happened is that too few people did the thing for Google to report it.\n\tsuppressed: 'Google hid it: too few people',\n\toutage: 'Not recorded during part of this period',\n\tsource_error: 'Source did not respond',\n\t// \"Does not apply\" reads as a choice someone made. It is not — there is simply nothing here to\n\t// compute it from.\n\tnot_applicable: 'Nothing here to work this out from',\n\t// Its own reason, because it was borrowing not_applicable — so a figure that is temporarily\n\t// missing rendered \"Does not apply to this site. This site's API route predates this figure.\"\n\t// Two flatly contradictory sentences, and a hurried reader takes the first: this foundry has no\n\t// revenue. That is the mistake the top of this file exists to forbid, one layer up.\n\troute_outdated: 'Not available from this site yet',\n}\n\n/**\n * One locale for every number the tool prints.\n *\n * Counts were hard-coded to en-GB while money passed `undefined` and took the viewer's locale, so\n * on a non-en-GB Studio a single table row read \"1,234\" sessions beside \"1.234,00 $\" — two\n * separator conventions in the same row. Whichever convention is chosen, it has to be the same one\n * on both sides of a comparison.\n */\nconst LOCALE = 'en-GB'\n\n/** Format a number with thousands separators. */\nexport function formatCount(value: number): string {\n\treturn new Intl.NumberFormat(LOCALE).format(Math.round(value))\n}\n\n/** Format a 0–1 ratio as a percentage. */\nexport function formatPercent(ratio: number, digits = 0): string {\n\treturn `${(ratio * 100).toFixed(digits)}%`\n}\n\n/** Props for MetricFigure. */\n/** Format a money value in the site's currency, falling back to a plain number. */\nexport function formatMoney(value: number, currency: string | null, fractionDigits: 0 | 2 = 0): string {\n\tif (!currency) return formatCount(Math.round(value))\n\ttry {\n\t\treturn new Intl.NumberFormat(LOCALE, {\n\t\t\tstyle: 'currency',\n\t\t\tcurrency,\n\t\t\t// One precision for the whole column, chosen by the caller, not by each value's own size.\n\t\t\t// Switching at 1,000 put \"$850.00\" and \"$1,200\" in adjacent rows, and printed an axis\n\t\t\t// reading \"$1,000\" over a baseline reading \"$0.00\" — the mismatch the timeline's own\n\t\t\t// comment claims to have fixed by routing both ends through this function.\n\t\t\tmaximumFractionDigits: fractionDigits,\n\t\t\tminimumFractionDigits: fractionDigits,\n\t\t}).format(value)\n\t} catch {\n\t\t// An unrecognised ISO code must not blank the figure.\n\t\treturn `${formatCount(Math.round(value))} ${currency}`\n\t}\n}\n\n/** Props for Delta. */\nexport interface DeltaProps {\n\t/** This period's value, or null when it could not be measured. */\n\tcurrent: number | null\n\t/** The previous equivalent period's value, or null when there is no comparison. */\n\tprevious: number | null\n\t/**\n\t * Whether a rise is good. Sessions and revenue: yes. A shortfall or a bounce figure: no.\n\t * Drives only the wording and the tone, never whether the number is shown.\n\t */\n\triseIsGood?: boolean\n\t/** Render as a percentage-point change rather than a percentage change of a percentage. */\n\tunit?: 'count' | 'percent'\n}\n\n/**\n * A period-over-period change.\n *\n * Every figure in this tool used to be a bare level, which at these volumes is close to\n * meaningless: \"389 sessions\" is neither good nor bad without last week beside it, and the\n * 24 August collapse would have announced itself on every panel as a delta while going unnoticed\n * for over a week as a level.\n *\n * Renders nothing at all when there is no comparison — an absent delta must never be drawn as\n * \"no change\", which is a different and much more reassuring claim.\n */\n/**\n * How large a baseline must be before a percentage change means anything.\n *\n * Twenty-five. At a foundry's volumes the headline cards routinely compare single digits: seven\n * orders against four rendered \"↑ +75%\" in the largest type on the default tab, where one order\n * moves it twenty-five points. The server layer refuses far better-supported claims than that —\n * it will not rank a typeface on 28 views, and it withholds a funnel rate under a denominator of\n * 30 — while this component had no floor at all and reintroduced the claim in the presentation.\n *\n * Below the floor the figures are still shown; only the PERCENTAGE is withheld. \"7 orders, was 4\"\n * is the whole fact and is not improved by a ratio.\n */\nexport const MIN_DELTA_BASE = 25\n\nexport function Delta({ current, previous, riseIsGood = true, unit = 'count' }: DeltaProps): React.ReactElement | null {\n\t// isFinite, not a null check. An older API route sends undefined for a field it does not know\n\t// about, and a NaN can reach here from a division the server got wrong — both pass `!== null`\n\t// and render \"NaN%\" beside a confident arrow.\n\tif (!Number.isFinite(current as number) || !Number.isFinite(previous as number)) return null\n\tconst now = current as number\n\tconst before = previous as number\n\n\tconst absolute = now - before\n\n\t// Flat means \"rounds to nothing at the precision this prints\", not \"identical to the last bit\".\n\t//\n\t// Only an exact zero counted as flat, so a percent move of 0.04 points printed \"↑ +0.0 pts\" and\n\t// a count move of 2 on 1,000 printed \"↑ 0%\" — an arrow and a direction attached to a magnitude\n\t// of zero, which is a claim the figure has already rounded away. It fires on the Acquisition\n\t// share cards, whose inputs are floats.\n\tconst roundsToNothing = unit === 'percent'\n\t\t? Math.abs(absolute) < 0.05\n\t\t: before !== 0 && Math.abs(absolute / Math.abs(before)) < 0.005\n\tif (absolute === 0 || roundsToNothing) {\n\t\t// Every other branch ends in \"from X\". This one used to stop at two words, so the single\n\t\t// case where a reader most wants to verify the claim was the one case with no number — and\n\t\t// with no arrow either, it read as an ABSENT comparison rather than a measured flat one.\n\t\treturn (\n\t\t\t<span style={deltaStyle()}>\n\t\t\t\tno change<span style={baselineStyle}> from {unit === 'percent' ? `${before.toFixed(1)}%` : formatCount(before)}</span>\n\t\t\t</span>\n\t\t)\n\t}\n\n\t// A change from zero has no defined percentage in either direction. \"+∞%\" is what a naive\n\t// division produces, and asserting `change` non-null let a fall to zero print \"↓ 0%\".\n\tconst change = before === 0 ? null : absolute / Math.abs(before)\n\tconst rising = absolute > 0\n\tconst arrow = rising ? '\\u2191' : '\\u2193'\n\n\t// The baseline is formatted in the figure's own unit. Reading \"Previous period: 43\" under a\n\t// 43.2% consent rate is the same defect MetricFigure's `unit` was added to fix.\n\tconst baseline = unit === 'percent' ? `${before.toFixed(1)}%` : formatCount(before)\n\n\t// Too small a baseline for a ratio to mean anything — see MIN_DELTA_BASE. Placed AFTER the\n\t// zero-baseline case, which has its own wording: a first-ever order is \"new\", not \"was 0\".\n\t// The DIRECTION stays, and stays true; only the percentage is withheld, because at these counts\n\t// the percentage is the part that misleads. Counts only — a percentage-point move on a rate\n\t// carries its own denominator, which this component cannot see.\n\tconst tooFewToRate = change !== null && unit !== 'percent' && Math.abs(before) < MIN_DELTA_BASE\n\n\tconst magnitude = change === null\n\t\t? (rising ? 'new' : 'gone')\n\t\t: tooFewToRate\n\t\t? ''\n\t\t: unit === 'percent'\n\t\t\t// Percentage points, not a percentage of a percentage: a consent rate moving 40% → 44%\n\t\t\t// rose by 4 points, and calling that \"+10%\" is a different and confusing claim.\n\t\t\t? `${rising ? '+' : ''}${absolute.toFixed(1)} pts`\n\t\t\t: `${rising ? '+' : ''}${formatPercent(change, 0)}`\n\n\treturn (\n\t\t// The baseline is printed, not hidden in a `title`. At seven orders a quarter an 18% move\n\t\t// might be one order, so \"from 6\" is the fact and the percentage is the decoration — and a\n\t\t// title attribute is invisible on touch, in a screenshot, and to anyone who does not hover.\n\t\t<span style={deltaStyle()}>\n\t\t\t<span aria-hidden=\"true\">{arrow}</span>\n\t\t\t{magnitude ? ` ${magnitude}` : ''}\n\t\t\t<span style={baselineStyle}> from {baseline}</span>\n\t\t\t{/* Said, rather than left as a gap after the arrow. `magnitude` is empty exactly when the\n\t\t\t baseline is too small to carry a percentage, and \"↑ from 4\" read as a bug. */}\n\t\t\t{tooFewToRate && <span style={baselineStyle}> · too few to rate</span>}\n\t\t</span>\n\t)\n}\n\n/**\n * Delta styling. Direction is carried by the arrow and the words as well as the colour, so the\n * meaning survives a monochrome or colour-blind reading.\n */\nfunction deltaStyle(): React.CSSProperties {\n\t// ONE colour for everything that refers to the other window — see COMPARISON in palette.ts.\n\t//\n\t// This used to vary by tone: opacity 0.9 or 0.55, plus a rule under a bad move and none under a\n\t// good one. Three cards side by side therefore showed three different treatments, and a reader\n\t// reported it as an inconsistency rather than reading it as meaning. It was meaning — but\n\t// meaning already carried twice over, by the arrow and by the sign on the magnitude, so the\n\t// third channel bought nothing and cost the identity.\n\t//\n\t// The underline had a second problem once the text was coloured: a coloured, underlined inline\n\t// span is the browser's hyperlink signature, and these sit on the same screens as real links.\n\t//\n\t// Full alpha is not a preference. COMPARISON clears 3:1 only at alpha 1; the opacity ladder\n\t// this replaces put it at 2.1.\n\treturn {\n\t\tfontFamily: 'inherit',\n\t\tfontSize: '0.8em',\n\t\tfontWeight: 500,\n\t\tcolor: COMPARISON_TEXT,\n\t\twhiteSpace: 'nowrap',\n\t}\n}\n\n/**\n * The baseline inside a delta.\n *\n * Same colour, one step down in weight. Weight alone separates \"the move\" from \"what it moved from\"\n * without breaking the identity or dropping below the contrast floor.\n */\nconst baselineStyle: React.CSSProperties = { color: COMPARISON_TEXT, fontWeight: 400 }\n\n/** Gap between the crosshair and the card, on whichever side it lands. */\nconst HOVER_GUTTER = 14\n\n/** The card's widest possible layout — `hoverCard.maxWidth`, which the flip has to respect. */\nconst HOVER_CARD_MAX = 260\n\nexport function HoverCard({\n\tx,\n\tpaneWidth,\n\tdate,\n\trows,\n\tevents,\n}: {\n\t/** Plot-space x of the crosshair, in CSS pixels. */\n\tx: number\n\t/** The measured pane width, for the flip decision. */\n\tpaneWidth: number\n\t/** The day, already formatted. */\n\tdate: string\n\t/** One entry per series, in the stack's own order. */\n\trows: Array<{ key: string; label: string; color: string; value: string; seen: string | null }>\n\t/** Campaign sends or other markers on this day. */\n\tevents: string[]\n}): React.ReactElement {\n\t// Flip rather than clamp. A clamped card stops tracking the crosshair and then sits ON the\n\t// thing it describes for the whole right-hand edge.\n\t//\n\t// The threshold is the card's own width, not a fraction of the pane. A fixed 0.66 was right on\n\t// a wide pane and wrong on a narrow one: at 400px a crosshair at 60% left 146px of room for a\n\t// card that wants up to 260, and `frameStyle`'s overflow:hidden took the rest off.\n\tconst flip = x + HOVER_GUTTER + HOVER_CARD_MAX > paneWidth\n\treturn (\n\t\t<div\n\t\t\tstyle={{\n\t\t\t\t...hoverCard,\n\t\t\t\tleft: flip ? undefined : x + HOVER_GUTTER,\n\t\t\t\tright: flip ? paneWidth - x + HOVER_GUTTER : undefined,\n\t\t\t}}\n\t\t>\n\t\t\t<div style={hoverDate}>{date}</div>\n\t\t\t{rows.map((row) => (\n\t\t\t\t<div key={row.key} style={hoverRow}>\n\t\t\t\t\t{/* The same block that labels the row in the plot, so the two are one legend. */}\n\t\t\t\t\t<span style={{ ...hoverSwatch, background: row.color }} />\n\t\t\t\t\t<span style={hoverLabel}>{row.label}</span>\n\t\t\t\t\t<span style={hoverValue}>\n\t\t\t\t\t\t{row.value}\n\t\t\t\t\t\t{row.seen && <span style={hoverSeen}> · {row.seen}</span>}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t))}\n\t\t\t{events.map((event) => (\n\t\t\t\t<div key={event} style={hoverEvent}>{event}</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n\n/**\n * The floating card.\n *\n * Its ground is the Studio's own card colour so it reads as a surface rather than a tooltip drawn\n * by the chart, and it carries a border because at low contrast a shadow alone disappears on the\n * dark theme.\n */\nconst hoverCard: React.CSSProperties = {\n\tposition: 'absolute',\n\ttop: 8,\n\t// Never the element under the pointer: the card follows the crosshair, and taking a pointer\n\t// event would end the hover that positions it.\n\tpointerEvents: 'none',\n\tzIndex: 2,\n\tminWidth: 168,\n\tmaxWidth: HOVER_CARD_MAX,\n\tpadding: '8px 10px',\n\tborderRadius: 4,\n\tbackground: 'var(--card-bg-color, #ffffff)',\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.35))',\n\tboxShadow: '0 4px 14px rgba(0,0,0,0.18)',\n\tdisplay: 'flex',\n\tflexDirection: 'column',\n\tgap: 4,\n}\n\n/** The day, which is the card's heading. */\nconst hoverDate: React.CSSProperties = { fontSize: 11, fontWeight: 600, opacity: 0.9, marginBottom: 2 }\n\n/** One series line: swatch, name, value. */\nconst hoverRow: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, lineHeight: 1.4 }\n\n/** The colour block, matching the one beside the row's name in the plot. */\nconst hoverSwatch: React.CSSProperties = { width: 8, height: 8, borderRadius: 2, flex: '0 0 auto' }\n\n/** The series name, which yields its space to the figure. */\nconst hoverLabel: React.CSSProperties = { opacity: 0.75, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }\n\n/** The figure, pushed right and tabular so a column of them lines up. */\nconst hoverValue: React.CSSProperties = { marginLeft: 'auto', fontVariantNumeric: 'tabular-nums', fontWeight: 500, whiteSpace: 'nowrap' }\n\n/** What the lossier source saw of the same thing, quieter than the answer it qualifies. */\nconst hoverSeen: React.CSSProperties = { opacity: 0.6, fontWeight: 400 }\n\n/** A campaign send or other dated marker on the hovered day. */\nconst hoverEvent: React.CSSProperties = { fontSize: 11, opacity: 0.8, borderTop: '1px solid var(--card-border-color, rgba(128,128,128,0.25))', paddingTop: 4, marginTop: 2 }\n\n\nexport interface MetricFigureProps {\n\tmetric: MetricValue\n\t/** Accessible label describing what this number counts. */\n\tlabel: string\n\tsize?: number\n\t/**\n\t * What the number IS, which decides how it is written.\n\t *\n\t * MetricValue carries availability but not unit, so a percentage and a count arrive here\n\t * indistinguishable and both used to be written with the count formatter. The consent rate is a\n\t * 0-100 percentage: it rendered as a bare \"84\" beside \"GA4 sessions 357\" and \"Orders 7\", where\n\t * the obvious reading is 84 sessions out of 357 — a quarter — when the truth is 84%. A missing\n\t * suffix inverted the conclusion.\n\t *\n\t * 'percent' expects a 0-100 value, matching what measurementHealth produces.\n\t *\n\t * 'money' writes the figure in the range's currency. Without it a PARTIAL revenue reached the\n\t * count formatter, so Darden's default tab — where revenue is permanently partial, because most\n\t * orders predate the amount field — rendered its headline money as a bare \"12,346\", beside an\n\t * \"Average order US$455\" that did carry a currency.\n\t */\n\tunit?: 'count' | 'percent' | 'money'\n\t/** ISO 4217 code. Read only when `unit` is 'money'. */\n\tcurrency?: string | null\n}\n\n/**\n * The badge on a partial figure.\n *\n * It was the fixed string \"Some orders only\", written for the revenue case and then applied to\n * every partial metric in the tool — including the GA4 view counts on Typeface interest, where it\n * is simply false: those are pageviews spanning an event cutover, and no order is involved.\n *\n * `coveredFrom` distinguishes the two. A cutover or an outage carries the date coverage begins; a\n * gap in the order book does not.\n *\n * @param metric - the partial value\n */\nfunction partialBadge(metric: { coveredFrom?: string }): string {\n\treturn metric.coveredFrom ? `From ${formatDay(metric.coveredFrom)} only` : 'Some orders only'\n}\n\n/**\n * Render a metric value, handling the absent case visibly.\n * Screen readers get the reason text rather than an unexplained dash.\n */\nexport function MetricFigure({ metric, label, size = 4, unit = 'count', currency = null }: MetricFigureProps): React.ReactElement {\n\tif (metric.status === 'unavailable') {\n\t\t// Guarded: the route can be newer than the Studio and send a reason this build has never\n\t\t// heard of, which rendered the word \"undefined\" to the reader.\n\t\tconst reason = REASON_TEXT[metric.reason] ?? 'No figure available'\n\t\tconst detail = metric.detail ? `${reason}. ${metric.detail}` : reason\n\n\t\treturn (\n\t\t\t// The reason travels as REAL TEXT in a visually-hidden span, not as an aria-label and not\n\t\t\t// only in a tooltip.\n\t\t\t//\n\t\t\t// `aria-label` on a <Text> is silently dropped: it renders a bare <div>, whose role is\n\t\t\t// `generic`, on which aria-label is prohibited by ARIA and ignored by every major\n\t\t\t// browser. The em dash beside it is aria-hidden. So an unavailable metric announced as\n\t\t\t// an EMPTY NODE — a blank cell — which is exactly the \"absent must never look like a\n\t\t\t// measured zero\" invariant this whole type exists to enforce, inverted for anyone not\n\t\t\t// looking at the screen. The tooltip was the only other route and it hangs off a\n\t\t\t// non-focusable element, so it was pointer-only too.\n\t\t\t//\n\t\t\t// Delta already does this correctly; this is that pattern applied.\n\t\t\t<Tooltip content={<Box padding={2}><Text size={1}>{detail}</Text></Box>} portal>\n\t\t\t\t<Text size={size} muted>\n\t\t\t\t\t<span aria-hidden=\"true\">—</span>\n\t\t\t\t\t<span style={visuallyHidden}>{label}: unavailable. {detail}</span>\n\t\t\t\t</Text>\n\t\t\t</Tooltip>\n\t\t)\n\t}\n\n\t// Percentages keep one decimal, since that is the precision the server produced; rounding to a\n\t// whole number here would make a 0.4-point move look like no move at all.\n\tconst formatted = unit === 'percent'\n\t\t? `${metric.value.toFixed(1)}%`\n\t\t: unit === 'money'\n\t\t\t? formatMoney(metric.value, currency)\n\t\t\t: formatCount(metric.value)\n\n\tif (metric.status === 'partial') {\n\t\treturn (\n\t\t\t<Stack space={2}>\n\t\t\t\t<Text size={size}>\n\t\t\t\t\t{formatted}\n\t\t\t\t\t<span style={visuallyHidden}>, partial. {metric.note}</span>\n\t\t\t\t</Text>\n\t\t\t\t<Badge tone=\"caution\" fontSize={0}>{partialBadge(metric)}</Badge>\n\t\t\t</Stack>\n\t\t)\n\t}\n\n\tif (metric.status === 'estimated') {\n\t\t// Marked as inferred at every level: a tilde on the number, the interval beneath, a badge,\n\t\t// and the basis in the accessible name. Adding the variant to the union was not enough on\n\t\t// its own — this branch did not exist at first, so an estimate fell through to the plain\n\t\t// return below and rendered as a measured figure, which is the one thing the variant was\n\t\t// introduced to make impossible.\n\t\tconst range = unit === 'percent'\n\t\t\t? `${metric.low.toFixed(1)}% to ${metric.high.toFixed(1)}%`\n\t\t\t: unit === 'money'\n\t\t\t\t? `${formatMoney(metric.low, currency)} to ${formatMoney(metric.high, currency)}`\n\t\t\t\t: `${formatCount(metric.low)} to ${formatCount(metric.high)}`\n\n\t\treturn (\n\t\t\t<Stack space={2}>\n\t\t\t\t<Text size={size}>\n\t\t\t\t\t<span aria-hidden=\"true\">~</span>{formatted}\n\t\t\t\t\t<span style={visuallyHidden}>estimated, between {range}. {metric.basis}</span>\n\t\t\t\t</Text>\n\t\t\t\t<Text size={0} muted>{range}</Text>\n\t\t\t\t<Badge tone=\"primary\" fontSize={0}>Estimated</Badge>\n\t\t\t</Stack>\n\t\t)\n\t}\n\n\tif (metric.status !== 'ok') {\n\t\t// Exhaustiveness guard. `estimated` was added to the union and rendered as a measurement\n\t\t// for exactly as long as it took to notice; this makes the compiler refuse the next one.\n\t\tconst unhandled: never = metric\n\t\tvoid unhandled\n\t}\n\n\treturn <Text size={size}>{formatted}</Text>\n}\n\n/** Props for ChartData. */\nexport interface ChartDataProps<Row> {\n\t/** What the disclosure is called. Names the chart it belongs to. */\n\tlabel: string\n\trows: Row[]\n\tcolumns: Array<SortColumn<Row>>\n\trowKey: (row: Row) => string\n\texportName?: string\n\t/** A line above the table, for anything a column header cannot carry. */\n\tnote?: string\n\t/** Which column to filter on, if the table should offer a filter box. */\n\tfilterOn?: (row: Row) => string\n}\n\n/**\n * The figures behind a chart, as a table, collapsed by default.\n *\n * A chart is a picture of the data and not the data. Every other panel here renders a table for\n * exactly that reason — the file header used to say so, and it stopped being true the moment the\n * charts landed, leaving two time series with no equivalent anywhere in the tool.\n *\n * One disclosure serves four readers at once: someone using a screen reader, for whom an SVG under\n * `role=\"img\"` is a single opaque node; someone navigating by keyboard; someone who cannot resolve\n * axis type at any size; and anyone who wants these numbers in a spreadsheet, since the table\n * brings sorting and CSV copy with it. Collapsed, so it costs a sighted reader one line.\n */\nexport function ChartData<Row>({ label, rows, columns, rowKey, exportName, note, filterOn }: ChartDataProps<Row>): React.ReactElement | null {\n\tif (rows.length === 0) return null\n\n\treturn (\n\t\t<details style={disclosureBlock}>\n\t\t\t<summary style={disclosureSummary}>{label}</summary>\n\t\t\t<div style={{ marginTop: 10 }}>\n\t\t\t\t{note && <Text size={0} muted style={{ marginBottom: 8, display: 'block' }}>{note}</Text>}\n\t\t\t\t<SortableTable<Row>\n\t\t\t\t\tcaption={label}\n\t\t\t\t\trows={rows}\n\t\t\t\t\tcolumns={columns}\n\t\t\t\t\trowKey={rowKey}\n\t\t\t\t\texportName={exportName}\n\t\t\t\t\tfilterOn={filterOn}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</details>\n\t)\n}\n\n/** The disclosure wrapper for a chart's figures. */\nconst disclosureBlock: React.CSSProperties = { width: '100%' }\n\n/** Its control. Underlined so it reads as actionable, like the caveat disclosure. */\nconst disclosureSummary: React.CSSProperties = {\n\tcursor: 'pointer',\n\tfont: 'inherit',\n\tfontSize: '0.85em',\n\topacity: 0.75,\n\ttextDecoration: 'underline',\n\ttextUnderlineOffset: 3,\n\twidth: 'fit-content',\n}\n\n/** One bar of a proportion chart. */\nexport interface ProportionBar {\n\tkey: string\n\tlabel: string\n\tsublabel?: string\n\tvalue: number\n}\n\n/** Props for ProportionChart. */\nexport interface ProportionChartProps {\n\tbars: ProportionBar[]\n\t/** How to write each value out. */\n\tformat: (value: number) => string\n\t/** What the bars sum to, named — a share is meaningless without its denominator stated. */\n\ttotalLabel: string\n}\n\n/**\n * A ranked part-to-whole bar chart.\n *\n * For a breakdown whose rows sum to something meaningful — licence revenue by tier, say. Each bar\n * carries its share of the total AND its absolute value, because a share alone hides that the\n * leading row might be two orders, and an absolute alone hides that it is most of the business.\n *\n * Bars scale against the SUM rather than against the largest row, so the widths read as shares of\n * the whole. Scaling to the max would make the top row full-width whatever it was worth, which is\n * the same misreading the funnel avoids by anchoring to its entry step.\n */\nexport function ProportionChart({ bars, format, totalLabel }: ProportionChartProps): React.ReactElement | null {\n\tif (bars.length === 0) return null\n\n\tconst total = bars.reduce((sum, bar) => sum + bar.value, 0)\n\tif (total <= 0) return null\n\n\tconst ordered = [...bars].sort((a, b) => b.value - a.value)\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t{ordered.map((bar) => {\n\t\t\t\tconst share = bar.value / total\n\t\t\t\treturn (\n\t\t\t\t\t<Stack space={1} key={bar.key}>\n\t\t\t\t\t\t<div style={barHeader}>\n\t\t\t\t\t\t\t<Text size={1} weight=\"medium\">\n\t\t\t\t\t\t\t\t{bar.label}\n\t\t\t\t\t\t\t\t{bar.sublabel && <Text size={0} muted as=\"span\">{' '}· {bar.sublabel}</Text>}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\t{format(bar.value)}\n\t\t\t\t\t\t\t\t<Text size={0} muted as=\"span\">{' '}({formatPercent(share, 0)})</Text>\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div aria-hidden=\"true\" style={barTrack}>\n\t\t\t\t\t\t\t<div style={{ ...barFill, width: `${Math.max(1, share * 100)}%` }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Stack>\n\t\t\t\t)\n\t\t\t})}\n\t\t\t<Text size={0} muted>{totalLabel}: {format(total)}</Text>\n\t\t</Stack>\n\t)\n}\n\n/** One rung of the funnel, already filtered to steps that are actually measured. */\nexport interface FunnelStage {\n\tkey: string\n\tlabel: string\n\t/** Distinct users at this step. */\n\tvalue: number\n\t/** Share of the previous shown step, or null when it could not be computed. */\n\tconversionFromPrevious: number | null\n\t/**\n\t * Set when this step was only instrumented part-way through the window.\n\t *\n\t * The count is real but it covers fewer days than the rungs around it, so any ratio drawn\n\t * across the two is arithmetic over mismatched windows. `JourneyPanel` used to drop the\n\t * metric's `partial` status on the way in, so a `view_item` that began firing on 1 September\n\t * was drawn identically to a full-month `page_view` and captioned \"33.9% of landed\" — a\n\t * thirteen-day numerator over a thirty-one-day denominator, stated to a decimal place.\n\t *\n\t * Everywhere else a partial metric carries a badge and a note; this was the one render site\n\t * that stripped it, and it is the most quoted number on the tab.\n\t */\n\tpartial?: { from: string }\n\t/**\n\t * This rung split by audience segment, in a stable order across rungs.\n\t *\n\t * The bar's WIDTH is unchanged — still this rung's share of the entry step — and the split\n\t * subdivides that width. So a segment collapsing reads as its portion shrinking down the column,\n\t * against a total that stays honestly anchored to entry.\n\t *\n\t * This replaces a second chart. `SurvivalLines` drew the same segments as normalised shares on\n\t * its own axis, above a funnel drawing the pooled counts: two pictures of one fact, and the\n\t * share-only one could not print a count, which at rungs of 23 and 12 and 7 is the only fact\n\t * that survives. Its denominator floor also silenced it at exactly those rungs.\n\t */\n\tsegments?: ReadonlyArray<{ key: string; label: string; value: number }>\n}\n\n/** Props for FunnelChart. */\nexport interface FunnelChartProps {\n\tstages: FunnelStage[]\n\t/**\n\t * Whether the stages are a tracked sequence or independent totals. The drawing is the same\n\t * shape either way, but the words between stages are not: a fallback's gap is a difference\n\t * between two counts, not people who dropped out.\n\t */\n\tmeasurement: 'sequence' | 'independent-totals'\n}\n\n/**\n * The smallest denominator a step-to-step rate may be computed on.\n *\n * Thirty. Below it one extra visitor moves the printed percentage by more than three points, so the\n * figure describes the arrival of a single person rather than the behaviour of a group — and at\n * these foundries' volumes the lower rungs of a funnel sit well under it. The count beside it is\n * still shown, because how many people reached a step is a fact at any size; it is the ratio that\n * needs a population.\n */\nexport const MIN_RATE_DENOMINATOR = 30\n\n/**\n * An ISO date as a reader would say it: `1 Sep`.\n *\n * The cutover note states the raw `2026-09-01`, which is right in a machine-written caveat and\n * wrong in a sentence under a chart.\n *\n * @param iso - a `YYYY-MM-DD` date\n */\nexport function formatDay(iso: string): string {\n\tconst date = new Date(`${iso}T00:00:00Z`)\n\tif (Number.isNaN(date.getTime())) return iso\n\treturn `${date.getUTCDate()} ${date.toLocaleString('en-GB', { month: 'short', timeZone: 'UTC' })}`\n}\n\n/**\n * The colour for one audience segment.\n *\n * Colour, not pattern. The chart this came from separated its lines by dash alone, which ran out at\n * three segments and was distorted by the stretched viewBox it was drawn in. The keys are borrowed\n * from the series palette because they already clear 3:1 on both grounds; they carry no source\n * meaning here, and the key beneath the funnel states which is which.\n *\n * @param index - the segment's position in the stack\n */\nfunction segmentColour(index: number): string {\n\tconst names = ['survival.line', 'survival.line.2', 'survival.line.3', 'survival.line.4']\n\treturn mark(names[index % names.length] as string)\n}\n\n/**\n * The segments present anywhere in the funnel, in first-seen order.\n *\n * Read off the stages rather than passed in, so the key cannot name a segment no rung draws — the\n * failure this package keeps finding in its own legends.\n *\n * @param stages - the rungs\n */\nfunction segmentKeys(stages: readonly FunnelStage[]): Array<{ key: string; label: string }> {\n\tconst seen = new Map<string, string>()\n\tfor (const stage of stages) {\n\t\tfor (const segment of stage.segments ?? []) {\n\t\t\tif (segment.value > 0 && !seen.has(segment.key)) seen.set(segment.key, segment.label)\n\t\t}\n\t}\n\treturn [...seen].map(([key, label]) => ({ key, label }))\n}\n\n/**\n * One segment's share of the rung it sits in.\n *\n * Clamped at the bottom only. A segment larger than the rung it belongs to is a real disagreement\n * between two GA4 queries rather than a rendering problem, and letting the row overflow its track\n * would hide it — so the shares are normalised by their own sum when they exceed the whole, which\n * keeps the bar's length truthful and lets the imbalance show as a changed proportion.\n *\n * @param value - the segment's count\n * @param whole - the rung's count\n */\nfunction segmentShare(value: number, whole: number): number {\n\tif (!(whole > 0) || !(value > 0)) return 0\n\treturn Math.min(1, value / whole)\n}\n\n/**\n * The line under one funnel rung.\n *\n * ONE clause, where there used to be two. The step-to-step rate was a second sentence on the same\n * line, each half with its own withhold-fallback — so on a six-rung funnel at a foundry's volumes\n * the panel printed \"too few to give a rate\" six times, twice per rung on three consecutive rungs.\n * It was the most repeated string in the tool, and the two clauses were the same picture twice:\n * the bars are anchored to entry, so share-of-entry is already drawn.\n *\n * Silence is the honest default when a rate is withheld — the count is still in the bar header, and\n * `shortListNote` elsewhere in this package already follows that rule. The one thing worth saying\n * out loud is a partial window, because that is a fact about the number rather than an absence of\n * one.\n *\n * @param stage - the rung\n * @param index - its position, so the entry rung can name the scale\n * @param entry - the first rung's count, which is the denominator and the rail's full width\n * @param share - stage over entry, unclamped\n * @param entryLabel - the first rung's name, for the sentence\n */\nfunction rateLine(\n\tstage: FunnelStage,\n\tindex: number,\n\tentry: number,\n\tshare: number,\n\tentryLabel: string,\n): React.ReactElement | null {\n\t// Turns the rail into a scale. \"entry step\" restated that the first bar was the first bar; this\n\t// says what a full-width bar means, which is the one thing the chart never stated.\n\tif (index === 0) return <Text size={0} muted>100% — everyone who arrived</Text>\n\n\t// A count measured over fewer days than its denominator cannot carry a rate against it.\n\t//\n\t// The wording matters as much as the withholding. \"Only counted from 1 Sep\" reads as a fault —\n\t// something broke, GA4 lost data — when the truth is the opposite: nobody was looking before\n\t// that date because the tracking did not exist. The server already builds that sentence and it\n\t// was being dropped on the way in.\n\tif (stage.partial) {\n\t\treturn (\n\t\t\t<Text size={0} muted>\n\t\t\t\ttracking added {stage.partial.from}, so this step covers fewer days than the ones above — no\n\t\t\t\tshare is shown\n\t\t\t</Text>\n\t\t)\n\t}\n\n\tconst canRate = entry >= MIN_RATE_DENOMINATOR && stage.value >= MIN_RATE_DENOMINATOR\n\tif (!canRate) return null\n\n\treturn <Text size={0} muted>{formatPercent(share, 1)} of {entryLabel.toLowerCase()}</Text>\n}\n\n/**\n * A funnel.\n *\n * Widths are a share of the FIRST stage rather than of the largest, which is what makes it read as\n * a funnel: every rung answers \"of everyone who arrived, how many got this far\". Anchoring to the\n * max instead would make the widest stage full-width wherever it sat, and a mid-funnel step wider\n * than entry — which happens on the independent-totals fallback, where `add_to_cart` can exceed\n * `page_view` — would silently rescale everything above it.\n *\n * Stages are focusable so the figures are reachable without a pointer; the readout is rendered as\n * text under the stage rather than as a floating tooltip so it is also visible on touch.\n */\nexport function FunnelChart({ stages, measurement }: FunnelChartProps): React.ReactElement | null {\n\tconst [activeKey, setActiveKey] = useState<string | null>(null)\n\tconst refs = useRef<Array<HTMLDivElement | null>>([])\n\n\tconst entry = stages[0]?.value ?? 0\n\tif (stages.length === 0) return null\n\n\tconst onKeyDown = (event: React.KeyboardEvent, index: number) => {\n\t\tlet next: number | null = null\n\t\tif (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = Math.min(index + 1, stages.length - 1)\n\t\tif (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = Math.max(index - 1, 0)\n\t\tif (event.key === 'Home') next = 0\n\t\tif (event.key === 'End') next = stages.length - 1\n\t\tif (next === null) return\n\t\tevent.preventDefault()\n\t\trefs.current[next]?.focus()\n\t}\n\n\t// Whether ANY rung had to withhold a rate. One sentence under the chart is the honest scope for\n\t// this: the rule is a property of the funnel, not of each rung, and stating it per rung made it\n\t// the most repeated string in the tool.\n\tconst withheldAny = stages.some((stage, index) =>\n\t\tindex > 0 && !stage.partial && !(entry >= MIN_RATE_DENOMINATOR && stage.value >= MIN_RATE_DENOMINATOR),\n\t)\n\n\treturn (\n\t\t<>\n\t\t<ol style={funnelList}>\n\t\t\t{stages.map((stage, index) => {\n\t\t\t\tconst previous = index > 0 ? stages[index - 1] : null\n\t\t\t\t// The share is printed unclamped and the WIDTH is clamped separately. Clamping the\n\t\t\t\t// share itself printed a fallback stage of 500 against an entry of 100 as \"100.0%\n\t\t\t\t// of landed\" — the one reading that hides the anomaly the caveat exists to explain.\n\t\t\t\tconst share = entry > 0 ? stage.value / entry : 0\n\t\t\t\tconst width = Math.max(1.5, Math.min(1, share) * 100)\n\t\t\t\tconst active = activeKey === stage.key\n\t\t\t\t// Whether this rung is split, and how much of it the named segments leave over.\n\t\t\t\tconst hasSplit = (stage.segments?.length ?? 0) > 0\n\t\t\t\tconst remainder = hasSplit\n\t\t\t\t\t? Math.max(0, 1 - (stage.segments ?? []).reduce((sum, s) => sum + segmentShare(s.value, stage.value), 0))\n\t\t\t\t\t: 0\n\t\t\t\tconst delta = previous ? previous.value - stage.value : 0\n\n\t\t\t\treturn (\n\t\t\t\t\t<li key={stage.key} style={funnelItem}>\n\t\t\t\t\t\t{previous && gapLabel(delta, measurement, entry) !== '' && (\n\t\t\t\t\t\t\t<div style={funnelGapStyle(measurement === 'sequence' && delta > 0 && entry > 0)}>\n\t\t\t\t\t\t\t\t{/* Drawn on the SAME RAIL as the stage bars, and that is the whole point.\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t This previously had a rail of its own at `width: 38%` with the fill a\n\t\t\t\t\t\t\t\t percentage OF THAT RAIL — so a drop-off of half your entrants rendered\n\t\t\t\t\t\t\t\t at 19% of the width a 50% stage bar occupies, understated by a factor\n\t\t\t\t\t\t\t\t of 2.6, beneath a comment claiming the two shared a scale. The test\n\t\t\t\t\t\t\t\t asserted `width: 75%` for a 750-of-1000 loss and passed, because the\n\t\t\t\t\t\t\t\t fill really was 75% of its own rail: it checked the arithmetic and\n\t\t\t\t\t\t\t\t never the relationship.\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t Full width, right-aligned, same 100% as every rung above and below. */}\n\t\t\t\t\t\t\t\t{measurement === 'sequence' && delta > 0 && entry > 0 && (\n\t\t\t\t\t\t\t\t\t<div aria-hidden=\"true\" style={{ ...barTrack, height: 6, flex: 1 }}>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tmarginLeft: 'auto',\n\t\t\t\t\t\t\t\t\t\t\t\theight: '100%',\n\t\t\t\t\t\t\t\t\t\t\t\tborderRadius: 2,\n\t\t\t\t\t\t\t\t\t\t\t\tbackground: mark('bar.lost'),\n\t\t\t\t\t\t\t\t\t\t\t\twidth: `${Math.max(1, Math.min(1, delta / entry) * 100)}%`,\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{/* NOT inside the aria-hidden. The whole gap block used to be hidden, so a\n\t\t\t\t\t\t\t\t screen-reader user got five stage counts and nothing about drop-off at\n\t\t\t\t\t\t\t\t all — including the warning that the gaps are not drop-offs under the\n\t\t\t\t\t\t\t\t fallback. Only the bar is decoration; the sentence is the finding. */}\n\t\t\t\t\t\t\t\t<Text size={0} muted>{gapLabel(delta, measurement, entry)}</Text>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tref={(el: HTMLDivElement | null) => {\n\t\t\t\t\t\t\t\trefs.current[index] = el\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\trole=\"listitem\"\n\t\t\t\t\t\t\tstyle={funnelStage(active)}\n\t\t\t\t\t\t\tonMouseEnter={() => setActiveKey(stage.key)}\n\t\t\t\t\t\t\tonMouseLeave={() => setActiveKey(null)}\n\t\t\t\t\t\t\tonFocus={() => setActiveKey(stage.key)}\n\t\t\t\t\t\t\tonBlur={() => setActiveKey(null)}\n\t\t\t\t\t\t\tonKeyDown={(e) => onKeyDown(e, index)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div style={barHeader}>\n\t\t\t\t\t\t\t\t<Text size={1} weight=\"medium\">{stage.label}</Text>\n\t\t\t\t\t\t\t\t<Text size={1} weight=\"semibold\">{formatCount(stage.value)}</Text>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{/* A partial rung gets NO BAR AT ALL — not a short one, and not a long one.\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t Drawing it at its raw share was the first mistake: the sentence said \"no share\n\t\t\t\t\t\t\t is shown\" while the geometry went on showing one, about half the length it\n\t\t\t\t\t\t\t should be. Drawing it as a full-width hatched rail was the second, and it was\n\t\t\t\t\t\t\t worse: in a column where LENGTH IS THE VALUE, that handed the least-measured\n\t\t\t\t\t\t\t rung the longest mark on the chart — a refusal drawn with more authority than\n\t\t\t\t\t\t\t any measurement on the page.\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t An empty slot in a column of bars cannot be mis-measured in either direction,\n\t\t\t\t\t\t\t and the count in the header above keeps it from reading as a zero. */}\n\t\t\t\t\t\t\t{stage.partial\n\t\t\t\t\t\t\t\t? <div style={partialSlot} />\n\t\t\t\t\t\t\t\t: (\n\t\t\t\t\t\t\t\t\t<div aria-hidden=\"true\" style={{ ...barTrack, height: 10 }}>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t...barFill,\n\t\t\t\t\t\t\t\t\t\t\t\twidth: `${width}%`,\n\t\t\t\t\t\t\t\t\t\t\t\tdisplay: 'flex',\n\t\t\t\t\t\t\t\t\t\t\t\toverflow: 'hidden',\n\t\t\t\t\t\t\t\t\t\t\t\t// Transparent when split. Left as the ordinary fill, the parent's own\n\t\t\t\t\t\t\t\t\t\t\t\t// colour showed through wherever the segments did not reach — and on a\n\t\t\t\t\t\t\t\t\t\t\t\t// rung where they sum to 451 of 475 that painted the missing 24 in the\n\t\t\t\t\t\t\t\t\t\t\t\t// FIRST segment's colour, reading as a third segment of it.\n\t\t\t\t\t\t\t\t\t\t\t\t...(hasSplit ? { background: 'transparent' } : {}),\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{/* The split subdivides the bar; it never changes its length. The rung's\n\t\t\t\t\t\t\t\t\t\t\t width stays its share of entry, so the funnel's scale is untouched and\n\t\t\t\t\t\t\t\t\t\t\t the segments read as portions of a total that is still anchored. */}\n\t\t\t\t\t\t\t\t\t\t\t{(stage.segments ?? []).map((segment, segmentIndex) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={segment.key}\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\twidth: `${segmentShare(segment.value, stage.value) * 100}%`,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackground: segmentColour(segmentIndex),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\theight: '100%',\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t{/* Whatever the named segments do not account for — a device category GA4\n\t\t\t\t\t\t\t\t\t\t\t reported under neither, or a rounding gap between two queries. Drawn in\n\t\t\t\t\t\t\t\t\t\t\t the neutral so it cannot be mistaken for a segment, and keyed as such. */}\n\t\t\t\t\t\t\t\t\t\t\t{hasSplit && remainder > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div style={{ width: `${remainder * 100}%`, background: mark('bar.track'), height: '100%' }} />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{/* Both ratios where they differ — the panel used to print only the\n\t\t\t\t\t\t\t step-to-step one, and a reader comparing two adjacent small\n\t\t\t\t\t\t\t percentages had no way to see how narrow the funnel had already\n\t\t\t\t\t\t\t become. On stage two the previous step IS entry, so printing both\n\t\t\t\t\t\t\t read as \"100.0% of landed · 500.0% of landed\". */}\n\t\t\t\t\t\t\t{/*\n\t\t\t\t\t\t\t * A rate is printed only where its denominator can carry one.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * This was the last figure in the tool still stating a small-sample number\n\t\t\t\t\t\t\t * with full authority. At seven orders a quarter and a fifth of traffic\n\t\t\t\t\t\t\t * captured, the lower rungs hold single digits — so \"33.3% of began\n\t\t\t\t\t\t\t * checkout\" was one visitor out of three, drawn to a decimal place and\n\t\t\t\t\t\t\t * sitting in the same type as a rate computed on hundreds. One more sale\n\t\t\t\t\t\t\t * moves it thirty points.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * The count still shows: how many people reached a step is a fact at any\n\t\t\t\t\t\t\t * size. It is the RATIO that needs a denominator, which is the same rule\n\t\t\t\t\t\t\t * the revenue split and the catalogue index already follow.\n\t\t\t\t\t\t\t */}\n\t\t\t\t\t\t\t{rateLine(stage, index, entry, share, stages[0]?.label ?? '')}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</li>\n\t\t\t\t)\n\t\t\t})}\n\t\t</ol>\n\t\t{/* The hatch gets a key. It is a new mark, drawn on the loudest rungs in the chart, and it\n\t\t shipped with nothing anywhere saying what it meant — in the same work whose whole subject\n\t\t was legends that do not match their graphs. */}\n\t\t{segmentKeys(stages).length > 0 && (\n\t\t\t<div style={funnelSegmentKey}>\n\t\t\t\t{segmentKeys(stages).map((segment, index) => (\n\t\t\t\t\t<Text key={segment.key} size={0} muted>\n\t\t\t\t\t\t<span aria-hidden=\"true\" style={{ ...hoverSwatch, background: segmentColour(index), display: 'inline-block', marginRight: 6 }} />\n\t\t\t\t\t\t{segment.label}\n\t\t\t\t\t</Text>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t)}\n\t\t{stages.some((stage) => stage.partial) && (\n\t\t\t<Text size={0} muted>\n\t\t\t\t{/* Explains the ABSENCE, now that there is no mark to key. A legend for a stripe that is\n\t\t\t\t no longer drawn would be the fault this work started from, in reverse. */}\n\t\t\t\tSteps with no bar were only tracked for part of this period, so they cover fewer days than\n\t\t\t\tthe ones above and cannot be drawn against them. Their counts are exact for the days they\n\t\t\t\tdo cover.\n\t\t\t</Text>\n\t\t)}\n\t\t{withheldAny && (\n\t\t\t<Text size={0} muted>\n\t\t\t\tShares are shown only for steps at least {MIN_RATE_DENOMINATOR} people reached. Below that a\n\t\t\t\tpercentage moves too far on one more visitor to mean anything; the counts above are exact.\n\t\t\t</Text>\n\t\t)}\n\t\t</>\n\t)\n}\n\n/**\n * The words in the gap between two rungs.\n *\n * A tracked funnel loses people; independent totals merely differ, and a later total can exceed an\n * earlier one — `add_to_cart` fires per selection on two of the three sites, so the cart step can\n * sit above `page_view`. That case must not print as \"no difference\", which is what a\n * greater-than-zero test alone produced.\n *\n * @param delta - previous step's value minus this one's; negative means this step is larger\n */\nfunction gapLabel(delta: number, measurement: 'sequence' | 'independent-totals', entry = 0): string {\n\t// On the fallback, only the ANOMALY is worth a line.\n\t//\n\t// The drop-off BAR is drawn for a tracked sequence only, so on independent totals every gap\n\t// label rendered right-aligned against empty space. \"N fewer\" was also arithmetic the reader can\n\t// do from the two counts either side of it, on every gap, under a caution card that specifically\n\t// forbids reading these gaps as drop-off — so it was noise that contradicted the warning above\n\t// it. A rung LARGER than the one above cannot be inferred that way and is the fallback's\n\t// characteristic surprise, so that one stays.\n\tif (measurement !== 'sequence' && delta >= 0) return ''\n\tif (delta === 0) return 'no drop-off'\n\tif (delta > 0) {\n\t\t// The share as well as the count, because the share is what the bar beside it encodes — and\n\t\t// a screen-reader user, who gets no bar at all, would otherwise have no way to know how big\n\t\t// the loss was relative to everyone who landed.\n\t\tconst share = entry > 0 ? ` — ${formatPercent(delta / entry, 0)} of everyone who landed` : ''\n\t\treturn `−${formatCount(delta)} did not continue${share}`\n\t}\n\t// A rung larger than the one above it. Under independent totals that is the fallback's\n\t// characteristic surprise — add_to_cart can exceed page_view — and the guard above routes it\n\t// here deliberately. Under a tracked sequence it should be impossible, which makes it worth\n\t// stating either way.\n\treturn `${formatCount(-delta)} more — not a subset of the step above`\n}\n\n/**\n * The space a bar would have occupied on a rung that has none.\n *\n * Reserved rather than collapsed, so the rungs keep their rhythm down the column and the missing\n * mark reads as deliberate rather than as something that failed to render.\n */\nconst partialSlot: React.CSSProperties = { height: 10 }\n\n/** The segment key, sat under the funnel with the other things said once per chart. */\nconst funnelSegmentKey: React.CSSProperties = { display: 'flex', gap: SPACE.group, flexWrap: 'wrap', paddingTop: SPACE.pair }\n\n/** One rung: tier, term, bar, figures. */\nconst ladderRow: React.CSSProperties = {\n\tdisplay: 'grid',\n\tgridTemplateColumns: 'minmax(80px, 1.1fr) minmax(60px, 0.8fr) minmax(60px, 2fr) auto',\n\tgap: SPACE.block,\n\talignItems: 'center',\n}\n\n/** The rail a rung's bar sits in. */\nconst ladderTrack: React.CSSProperties = {\n\tposition: 'relative',\n\theight: 8,\n\tborderRadius: 4,\n\toverflow: 'hidden',\n\tbackground: mark('bar.track'),\n}\n\n/** Orders and money, right-aligned and tabular so a column of them lines up. */\nconst ladderValue: React.CSSProperties = { textAlign: 'right', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }\n\n/** The apportioned share, quieter than the exact count it follows. */\nconst ladderMoney: React.CSSProperties = { opacity: 0.7 }\n\n/** The funnel's list wrapper. Numbering is suppressed — the rungs are already in order visually. */\nconst funnelList: React.CSSProperties = { listStyle: 'none', margin: 0, padding: 0 }\n\n/** One rung and its preceding gap. */\nconst funnelItem: React.CSSProperties = { display: 'grid', gap: 4 }\n\n/**\n * The space between two rungs, where the drop-off is named.\n *\n * Right-aligned only when there is a bar to align against. Under independent totals no bar is\n * drawn, so `flex-end` pushed the text to the right of nothing.\n */\nfunction funnelGapStyle(hasBar: boolean): React.CSSProperties {\n\treturn {\n\t\tdisplay: 'flex',\n\t\talignItems: 'center',\n\t\tjustifyContent: hasBar ? 'flex-end' : 'flex-start',\n\t\tgap: 8,\n\t\tpadding: '4px 2px',\n\t}\n}\n\n\n\n/** One rung. The active state is a background and a border, so it survives a forced-colours mode. */\nfunction funnelStage(active: boolean): React.CSSProperties {\n\treturn {\n\t\tdisplay: 'grid',\n\t\tgap: 6,\n\t\tpadding: '8px 10px',\n\t\tborderRadius: 3,\n\t\tborder: `1px solid ${active ? 'currentColor' : 'transparent'}`,\n\t\tbackground: active ? 'var(--card-bg-color, rgba(128,128,128,0.08))' : 'transparent',\n\t\tcursor: 'default',\n\t}\n}\n\n\n/**\n * The fill for a bar's measured portion.\n *\n * An explicit background, NOT `<Card tone=\"primary\">`. Sanity's card tones are page backgrounds\n * meant to sit behind text, never accents: measured against the surrounding track they come out at\n * 1.03:1 in light and 1.12:1 in dark, where WCAG's floor for a meaningful graphical object is 3:1.\n * Three of the four bar idioms were rendering as empty rails with numbers beside them, and\n * `tone=\"default\"` was worse still — a bar lighter than its own track.\n *\n * It also survives the compat shim, whose DOM fallback drops `tone`, `padding`, `radius` and\n * `border` entirely but passes `style` through. A bar whose existence depends on a resolved\n * design token is a bar that vanishes on the Studio versions the shim exists for.\n */\nconst barFill: React.CSSProperties = {\n\theight: '100%',\n\tborderRadius: 2,\n\t// Through the registry, not a hex plus an opacity chosen here. Drawn at 0.85 this sat at 2.77:1\n\t// on the light theme while palette.test.ts asserted 3.42:1 against the bare constant — the test\n\t// measured a colour that was never drawn. MARKS is what both now read.\n\tbackground: mark('bar.fill'),\n}\n\n/** The track a bar sits in. Visible on its own, so an empty bar still reads as a bar. */\nconst barTrack: React.CSSProperties = {\n\theight: 8,\n\tborderRadius: 2,\n\toverflow: 'hidden',\n\t// Furniture, not data: a rail is not a value, so 3:1 does not apply to it — but it is declared\n\t// in MARKS so that exemption is stated somewhere a reader can check rather than assumed.\n\tbackground: mark('bar.track'),\n}\n\n\n\n/** Bar header: label on the left, figure hard right, never overlapping on a narrow pane. */\nconst barHeader: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'center',\n\tjustifyContent: 'space-between',\n\tgap: 12,\n\tflexWrap: 'wrap',\n}\n\n\n/** Disclosure control for the collapsed caveats. Underlined so it reads as actionable text. */\nconst disclosure: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: 'none',\n\tcolor: 'inherit',\n\topacity: 0.75,\n\tfont: 'inherit',\n\tfontSize: '0.85em',\n\tpadding: '2px 0',\n\ttextDecoration: 'underline',\n\ttextUnderlineOffset: 3,\n\tcursor: 'pointer',\n\tjustifySelf: 'start',\n\twidth: 'fit-content',\n}\n\n/** Notice row: badge and text side by side, wrapping on a narrow panel rather than colliding. */\nconst noticeRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: 12,\n\talignItems: 'flex-start',\n\tflexWrap: 'wrap',\n}\n\n/** The badge keeps its width; only the text reflows. */\nconst noticeBadge: React.CSSProperties = { flex: '0 0 auto' }\n\n/** Props for NoticeList. */\nexport interface NoticeListProps {\n\tnotices: string[]\n}\n\n/**\n * Caveats attached to a report — sampling, processing lag, instrumentation cutovers.\n * Rendered in the panel rather than a README: a caveat nobody sees does not prevent a wrong read.\n */\nexport function NoticeList({ notices }: NoticeListProps): React.ReactElement | null {\n\tconst [expanded, setExpanded] = React.useState(false)\n\n\tif (notices.length === 0) return null\n\n\t// Two is the most a reader will actually take in before the band becomes wallpaper. Beyond that\n\t// the rest collapse behind a count, because a stack of seven identical amber cards above the\n\t// data trains people to skip the one that mattered — and every caveat here is emitted on every\n\t// panel, relevant or not, so the stack is routinely long.\n\tconst alwaysShown = notices.slice(0, 2)\n\tconst hidden = notices.slice(2)\n\n\tconst item = (notice: string) => (\n\t\t<li key={notice}>\n\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t{/* Laid out with real CSS rather than the UI kit's Flex and its `gap` token. When\n\t\t\t\t the compat shim cannot resolve Flex it renders a plain div, and a token number\n\t\t\t\t means nothing to CSS — so the badge and the text landed on top of each other\n\t\t\t\t and the notice read \"Caveasubscribe is counted…\". */}\n\t\t\t\t<div style={noticeRow}>\n\t\t\t\t\t<span style={noticeBadge}>\n\t\t\t\t\t\t<Badge tone=\"caution\" fontSize={0}>Note</Badge>\n\t\t\t\t\t</span>\n\t\t\t\t\t<Text size={1}>{notice}</Text>\n\t\t\t\t</div>\n\t\t\t</Card>\n\t\t</li>\n\t)\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t{/* A real list element, so a screen reader announces how many caveats there are. */}\n\t\t\t<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 8 }}>\n\t\t\t\t{alwaysShown.map(item)}\n\t\t\t\t{expanded && hidden.map(item)}\n\t\t\t</ul>\n\n\t\t\t{hidden.length > 0 && (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tstyle={disclosure}\n\t\t\t\t\tonClick={() => setExpanded((open) => !open)}\n\t\t\t\t\taria-expanded={expanded}\n\t\t\t\t>\n\t\t\t\t\t{expanded\n\t\t\t\t\t\t? 'Show fewer caveats'\n\t\t\t\t\t\t: `${hidden.length} more ${hidden.length === 1 ? 'caveat' : 'caveats'}`}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n\n/** One column of a sortable table. */\nexport interface SortColumn<Row> {\n\t/** Stable key, also used as the sort key. */\n\tkey: string\n\tlabel: string\n\t/** Right-aligned and sorted high-to-low first, the way a reader expects of a figure. */\n\tnumeric?: boolean\n\t/**\n\t * Value to sort on. Return null for \"no value\" — those always sort last regardless of\n\t * direction, because an unmeasured row is not a small one and must not lead an ascending sort.\n\t */\n\tsortValue: (row: Row) => number | string | null\n\t/**\n\t * Value written to the CSV, when it differs from the sort key.\n\t *\n\t * Defaults to `sortValue`, which is right for a plain count and wrong for anything formatted:\n\t * a rate sorts on 0.4318 and displays 43%, revenue sorts on a bare number and displays a\n\t * currency, and a column with a fallback displays something `sortValue` returns null for.\n\t */\n\texportValue?: (row: Row) => number | string | null\n\t/**\n\t * Keep this column even when every value in it is blank or zero.\n\t *\n\t * For the column that identifies the row — a table whose first column collapsed would be a list\n\t * of numbers belonging to nothing — and for any column whose emptiness is itself the finding.\n\t */\n\talwaysShow?: boolean\n\trender: (row: Row) => React.ReactNode\n}\n\n/**\n * Whether a column holds nothing worth a column.\n *\n * Empty means MEASURED and nothing: every visible row returns zero, or an empty string. A table of\n * a foundry's ten families with a Purchases column reading 0 ten times is the case this exists for\n * — the column is as wide as its heading and says only \"not this one\".\n *\n * NULL DOES NOT COUNT, and that is the whole distinction this package is built on. A column where\n * every value is unavailable is not an empty column, it is the finding that something could not be\n * measured — GA4 attributing no revenue to any source is the loudest thing the acquisition table\n * can tell you, and folding it away would turn the tool's best output into an absence the reader\n * never sees. A column of dashes is meant to be uncomfortable.\n *\n * This is still the one place in the package where a measured zero is treated as nothing, and it\n * is safe only because the table SAYS how many columns it folded and gives them back in one click.\n *\n * @param column - the column under test\n * @param rows - the rows actually on screen, after filtering and exclusion\n */\nexport function columnIsEmpty<Row>(column: SortColumn<Row>, rows: Row[]): boolean {\n\tif (column.alwaysShow) return false\n\t// A table with no rows has no empty columns — it has no columns' worth of evidence either way,\n\t// and folding every column of an empty table leaves a heading strip and nothing under it.\n\tif (rows.length === 0) return false\n\treturn rows.every((row) => {\n\t\tconst value = column.sortValue(row)\n\t\treturn value === 0 || value === ''\n\t})\n}\n\n/**\n * One titled region of a panel.\n *\n * Every section in this tool was a bare `Stack` with a `Heading size={1}` on top, which meant the\n * panel had exactly one level of hierarchy: the tab title, and then eleven things of equal weight.\n * A reader scanning for \"what should I look at\" got no help, because nothing on the page claimed to\n * matter more than anything else — and the two blocks that genuinely are reference material,\n * Configuration and Context, shouted as loudly as the traffic table.\n *\n * `tone` sets the weight; `collapsible` is what actually removes noise. A collapsed section renders\n * NO children at all rather than hiding them with CSS, so it costs nothing to draw and cannot be\n * found by a text search of the page — which is the honest behaviour when the reader has said they\n * do not want it.\n */\nexport function Section({\n\ttitle,\n\tsubtitle,\n\ttone = 'primary',\n\tcollapsible = false,\n\tdefaultOpen = true,\n\tchildren,\n}: {\n\ttitle: string\n\t/** One line under the heading. Longer explanation belongs inside, not here. */\n\tsubtitle?: React.ReactNode\n\t/** `secondary` is for reference material a reader consults rather than reads. */\n\ttone?: 'primary' | 'secondary'\n\t/** Whether the reader can fold it away. */\n\tcollapsible?: boolean\n\t/** Start folded, for material that is worth having and not worth reading every time. */\n\tdefaultOpen?: boolean\n\tchildren: React.ReactNode\n}): React.ReactElement {\n\tconst [open, setOpen] = React.useState(defaultOpen)\n\tconst bodyId = React.useId()\n\tconst shown = !collapsible || open\n\n\treturn (\n\t\t<section style={sectionBox}>\n\t\t\t<div style={sectionHeader}>\n\t\t\t\t<Stack space={1}>\n\t\t\t\t\t<Heading size={tone === 'primary' ? 1 : 0} style={tone === 'primary' ? primaryTitle : secondaryTitle}>\n\t\t\t\t\t\t{title}\n\t\t\t\t\t</Heading>\n\t\t\t\t\t{subtitle && <Text size={1} muted>{subtitle}</Text>}\n\t\t\t\t</Stack>\n\t\t\t\t{collapsible && (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tstyle={sectionToggle}\n\t\t\t\t\t\taria-expanded={open}\n\t\t\t\t\t\taria-controls={bodyId}\n\t\t\t\t\t\tonClick={() => setOpen((was) => !was)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{open ? 'Hide' : 'Show'}\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t{/* A Stack, not a bare div. See the note on sectionBox. */}\n\t\t\t{shown && <div id={bodyId} style={sectionBody}>{children}</div>}\n\t\t</section>\n\t)\n}\n\n/**\n * A section heading, without the fold.\n *\n * Most sections are not collapsible — they are the content — but they still need the hierarchy the\n * panel had none of: one weight for what you read, a quieter one for what you consult, and a rule\n * so the eye can find where one region ends and the next begins. `Section` uses the same two\n * treatments; this is for the blocks whose markup already owns its own stacking.\n */\nexport function SectionTitle({\n\ttitle,\n\ttone = 'primary',\n}: {\n\ttitle: string\n\t/** `secondary` is for reference material a reader consults rather than reads. */\n\ttone?: 'primary' | 'secondary'\n}): React.ReactElement {\n\treturn (\n\t\t<div style={sectionHeader}>\n\t\t\t<Heading size={tone === 'primary' ? 1 : 0} style={tone === 'primary' ? primaryTitle : secondaryTitle}>\n\t\t\t\t{title}\n\t\t\t</Heading>\n\t\t</div>\n\t)\n}\n\n/**\n * A part drawn INSIDE its whole, rather than beside it.\n *\n * The tool's central fact — GA4 sees a fifth of the traffic Vercel counts — was drawn as two peer\n * bars scaled to the larger of the pair. Vercel is always the larger, so its bar was always full\n * width on every site and every range: it carried no information at all, while a caption underneath\n * had to explain in words that one bar was \"a subset of the bar above, not a rival measurement\".\n * Two bars side by side encode RIVALS. The encoding asserted the opposite of the truth and the\n * prose was there to take it back.\n *\n * One bar says it without the sentence: the whole is the track, the part is filled inside it, and\n * what is left over is the quantity the reader came for.\n *\n * REFUSES when the part exceeds the whole. That happens for real — GA4 counts more than Vercel\n * wherever Vercel's collection started later than the range, which is routine on MCKL — and\n * containment is then simply the wrong picture. The caller falls back rather than this component\n * drawing a fill wider than its own track.\n */\n/**\n * Whether one figure is genuinely contained by another, so a part-inside-whole bar is honest.\n *\n * False where either side is unmeasured, and false where the part exceeds the whole — which is\n * real rather than hypothetical: GA4 counts more than Vercel wherever Vercel's collection started\n * after the range began, as it does on MCKL. Exported so the caller can choose its encoding rather\n * than discovering a null mid-render.\n *\n * @param whole - the complete count\n * @param part - the lossier count that should sit inside it\n */\nexport function isContainment(whole: MetricValue | undefined, part: MetricValue | undefined): boolean {\n\tconst w = valueOrNull(whole)\n\tconst p = valueOrNull(part)\n\treturn w !== null && p !== null && w > 0 && p <= w\n}\n\nexport function ContainmentBar({\n\twholeLabel,\n\twhole,\n\tpartLabel,\n\tpart,\n\tmissingLabel,\n}: {\n\twholeLabel: string\n\twhole: MetricValue\n\tpartLabel: string\n\tpart: MetricValue\n\t/** What the unfilled remainder means, e.g. \"not seen by GA4\". */\n\tmissingLabel: string\n}): React.ReactElement | null {\n\tconst wholeValue = valueOrNull(whole)\n\tconst partValue = valueOrNull(part)\n\n\t// Either side unmeasured, or the part larger than the whole: not a containment.\n\tif (!isContainment(whole, part)) return null\n\tif (wholeValue === null || partValue === null) return null\n\n\tconst share = partValue / wholeValue\n\tconst missing = wholeValue - partValue\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t<div style={barHeader}>\n\t\t\t\t<Text size={1}>{wholeLabel}</Text>\n\t\t\t\t<Text size={1} weight=\"medium\">{formatCount(wholeValue)}</Text>\n\t\t\t</div>\n\t\t\t{/* The track IS the whole. Nothing here is scaled to a maximum computed from the pair,\n\t\t\t so neither bar can be full width by construction. */}\n\t\t\t<div aria-hidden=\"true\" style={{ ...barTrack, background: mark('bar.fill'), height: 14 }}>\n\t\t\t\t<div style={{ height: '100%', width: `${share * 100}%`, background: mark('bar.seen'), borderRadius: 2 }} />\n\t\t\t</div>\n\t\t\t<div style={barHeader}>\n\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t{partLabel}: {formatCount(partValue)} ({formatPercent(share, 0)})\n\t\t\t\t</Text>\n\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t{formatCount(missing)} {missingLabel}\n\t\t\t\t</Text>\n\t\t\t</div>\n\t\t</Stack>\n\t)\n}\n\n/**\n * Three independent estimates of one number, on one axis.\n *\n * The capture model computes how much of reality GA4 is seeing three separate ways — against the\n * order book, against email clicks, against Vercel — and `capture.ts` is explicit that they are\n * deliberately NOT averaged, because the DISAGREEMENT between them is the useful output. Rendered\n * as three cards in a grid, that disagreement is a subtraction the reader performs by eye, and the\n * grid reorders itself by pane width so even their sequence is unstable.\n *\n * On one axis it is a distance. Three dots, a rule at 100%, and each dot carrying the interval its\n * own sample size leaves — so the estimate built on seven orders draws a bar most of the axis wide\n * and the reader can see, without being told, which of the three to discount.\n *\n * That interval already existed. `samplingInterval` has been in capture.ts throughout, documented\n * with \"printing that is the point\", and was drawn nowhere.\n *\n * No d3-shape: an axis, three rows, a rule. The scale opens past 100% because a rate above 1 is a\n * real fault this package reports rather than an impossibility — the same reason the coverage row\n * is no longer clamped.\n */\n/** What the plot needs of an estimate. Matches the core model's CaptureEstimate structurally. */\nexport interface EstimateForPlot {\n\tbasis: string\n\trate: number\n\tobserved: number\n\tactual: number\n\tnote: string\n}\n\nexport function EstimateDotPlot<E extends EstimateForPlot>({\n\testimates,\n\tlabelFor,\n\tintervalFor,\n}: {\n\testimates: ReadonlyArray<E>\n\t/** How to name a basis to the reader. */\n\tlabelFor: (basis: E['basis']) => string\n\t/**\n\t * The sampling interval for an estimate, from the core capture model.\n\t *\n\t * Takes the whole estimate rather than a narrowed shape: a structural `{ rate, actual }` looks\n\t * tidier and cannot be satisfied by `samplingInterval`, whose parameter is the full\n\t * CaptureEstimate — so the caller would have to wrap it for no benefit.\n\t */\n\tintervalFor: (estimate: E) => { low: number; high: number }\n}): React.ReactElement | null {\n\tif (estimates.length === 0) return null\n\n\tconst intervals = estimates.map((e) => intervalFor(e))\n\t// Opens past 1 when anything exceeds it, so an over-count is visible rather than pinned to the\n\t// right edge reading as \"perfect\".\n\tconst top = Math.max(1, ...estimates.map((e) => e.rate), ...intervals.map((i) => i.high))\n\tconst pct = (v: number) => `${(Math.max(0, Math.min(1, v / top))) * 100}%`\n\n\treturn (\n\t\t<Stack space={3}>\n\t\t\t{estimates.map((estimate, index) => {\n\t\t\t\tconst interval = intervals[index] as { low: number; high: number }\n\t\t\t\tconst wide = interval.high - interval.low > 0.4\n\t\t\t\treturn (\n\t\t\t\t\t<Stack key={estimate.basis} space={2}>\n\t\t\t\t\t\t<div style={barHeader}>\n\t\t\t\t\t\t\t<Text size={1}>{labelFor(estimate.basis)}</Text>\n\t\t\t\t\t\t\t<Text size={1} weight=\"medium\">{formatPercent(estimate.rate, 0)}</Text>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div aria-hidden=\"true\" style={estimateTrack}>\n\t\t\t\t\t\t\t{/* The rule first, so a dot at 100% sits on top of it rather than under. */}\n\t\t\t\t\t\t\t<div style={{ ...estimateRule, left: pct(1) }} />\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t...estimateInterval,\n\t\t\t\t\t\t\t\t\tleft: pct(interval.low),\n\t\t\t\t\t\t\t\t\twidth: `calc(${pct(interval.high)} - ${pct(interval.low)})`,\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<div style={{ ...estimateDot, left: pct(estimate.rate) }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t{formatCount(estimate.observed)} of {formatCount(estimate.actual)}\n\t\t\t\t\t\t\t{/* Said in words as well as drawn, because the bar is aria-hidden and because\n\t\t\t\t\t\t\t \"this one is too thin to lean on\" is the conclusion, not the picture. */}\n\t\t\t\t\t\t\t{wide && ' — too small a sample to lean on'}\n\t\t\t\t\t\t\t{/* EACH ESTIMATE'S OWN BIAS, which was written, required by this component's\n\t\t\t\t\t\t\t props type, and rendered nowhere.\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t Three dots share an axis so their DISAGREEMENT is legible — but each is\n\t\t\t\t\t\t\t wrong in a different direction, and that direction is the only thing telling\n\t\t\t\t\t\t\t a reader which dot to move toward. Pageviews is a ceiling; orders moves ~14\n\t\t\t\t\t\t\t points on one more GA4 purchase; email measures tagging as much as capture.\n\t\t\t\t\t\t\t Without them a reader seeing 45% and 20% has a spread and no way to resolve\n\t\t\t\t\t\t\t it — and on a Week range there is often only ONE dot, no discrepancy card,\n\t\t\t\t\t\t\t and nothing at all saying what that dot means. */}\n\t\t\t\t\t\t\t{estimate.note && <span style={estimateNote}>{estimate.note}</span>}\n\t\t\t\t\t\t\t{/* The CAUSE, not just the fact. \"Over 100%\" states the reading; \"usually a tag\n\t\t\t\t\t\t\t firing twice\" is the half a reader can act on, and dropping it when this\n\t\t\t\t\t\t\t moved from cards to a plot would have been a quiet loss — caught by the\n\t\t\t\t\t\t\t test that pinned it. */}\n\t\t\t\t\t\t\t{estimate.rate > 1.05 && ' — over 100%, so GA4 is counting more than the source it is checked against. That is usually a tag firing twice, not extra traffic.'}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t)\n\t\t\t})}\n\t\t\t{/* The shared footer is gone: three notes that each say what their own estimate is make a\n\t\t\t sentence saying \"each is a separate way of asking the same question\" redundant. The one\n\t\t\t fact the notes cannot carry is what the rule means, so that is all this says now. */}\n\t\t\t<Text size={0} muted>The rule marks 100%.</Text>\n\t\t</Stack>\n\t)\n}\n\n/** One licence line, as the order book records it. */\nexport interface LicenceLadderRow {\n\ttype: string\n\ttier: string\n\t/** The tier's rung on the price ladder. Rows are read in this order, never by size. */\n\ttierValue: number\n\tterm: string\n\torders: number\n\t/** The order total split evenly across the licences on it — a share, not a price. */\n\trevenue: number | null\n}\n\n/**\n * Licence sales, faceted by type, with each type's tiers in ladder order.\n *\n * Three dimensions, three channels: type is a facet, tier is vertical position within it, term is a\n * sub-row. The previous encoding flattened all three into one `type · tier` string on a single axis\n * and then sorted that axis by value — which is the one thing an ordinal ladder cannot survive, and\n * which put the two term-rows of a single tier as far apart as their values happened to fall. The\n * caption above it asked whether a tier that sells at one year also sells at perpetual; the drawing\n * separated exactly those two rows.\n *\n * Bars are ORDERS and share one scale across every facet, so a rung in Desktop is comparable with a\n * rung in Web. Orders because they are exact and unapportioned: a row's revenue is its order's total\n * divided evenly by the licences on that order, which is a share rather than a price, and ranking a\n * ladder by it ranks the split as much as the sales.\n */\nexport function LicenceLadder({ rows, currency }: {\n\trows: readonly LicenceLadderRow[]\n\tcurrency: string | null\n}): React.ReactElement | null {\n\tif (rows.length === 0) return null\n\n\t// One scale for the whole chart. Per-facet scaling would make a one-order type look as busy as\n\t// the type carrying the business.\n\tconst peak = Math.max(1, ...rows.map((row) => row.orders))\n\n\tconst types: Array<{ type: string; rungs: LicenceLadderRow[] }> = []\n\tfor (const row of rows) {\n\t\tconst facet = types.find((t) => t.type === row.type)\n\t\tif (facet) facet.rungs.push(row)\n\t\telse types.push({ type: row.type, rungs: [row] })\n\t}\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t{types.map((facet) => (\n\t\t\t\t<Stack key={facet.type} space={2}>\n\t\t\t\t\t<Text size={1} weight=\"semibold\">{facet.type}</Text>\n\t\t\t\t\t{facet.rungs.map((row, index) => {\n\t\t\t\t\t\t// The tier name prints once per rung; a term sharing the tier above it is\n\t\t\t\t\t\t// indented under it, which is what makes the two terms of one tier read as a pair.\n\t\t\t\t\t\tconst sameTierAsAbove = index > 0 && facet.rungs[index - 1]?.tier === row.tier\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div key={`${row.tier}\\u0000${row.term}`} style={ladderRow}>\n\t\t\t\t\t\t\t\t<Text size={0} muted>{sameTierAsAbove ? '' : row.tier}</Text>\n\t\t\t\t\t\t\t\t<Text size={0} muted>{row.term}</Text>\n\t\t\t\t\t\t\t\t<div aria-hidden=\"true\" style={ladderTrack}>\n\t\t\t\t\t\t\t\t\t<div style={{ ...barFill, width: `${Math.max(2, (row.orders / peak) * 100)}%` }} />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<Text size={0} style={ladderValue}>\n\t\t\t\t\t\t\t\t\t{formatCount(row.orders)}\n\t\t\t\t\t\t\t\t\t{row.revenue !== null && (\n\t\t\t\t\t\t\t\t\t\t<span style={ladderMoney}> · {formatMoney(row.revenue, currency)}</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</Stack>\n\t\t\t))}\n\t\t</Stack>\n\t)\n}\n\n/**\n * A ratio you can turn over to see what it is made of.\n *\n * At a foundry's volumes a derived rate is the least trustworthy thing on the page and often the\n * most quotable: \"337 visitors per order\" reads like a fact, and one more sale moves it to 295. The\n * package's answer everywhere else is to withhold — a rate below its denominator floor is simply\n * not printed. That is right for a rate the reader did not ask for and wrong for one they did,\n * because withholding leaves them with nothing.\n *\n * So: show it, and make its basis one click away. The counts face is not a footnote — it is the\n * same figure told honestly, and a reader who flips it once learns for themselves how thin the\n * arithmetic is. That is a better lesson than a caveat they skim.\n *\n * The toggle is per-card state and deliberately not persisted. It answers \"what is this made of\"\n * in the moment; remembering the answer across sessions would leave a reader staring at raw counts\n * with no memory of asking for them.\n */\nexport function RatioFigure({\n\tvalue,\n\tformat,\n\tparts,\n\tsize = 4,\n\tunavailable,\n}: {\n\t/** The ratio itself. Null when it could not be computed. */\n\tvalue: number | null\n\t/** How to render the ratio. */\n\tformat: (value: number) => string\n\t/** What it is made of, in reading order: numerator then denominator. */\n\tparts: ReadonlyArray<{ label: string; value: number | null; format?: (value: number) => string }>\n\tsize?: 0 | 1 | 2 | 3 | 4\n\t/** Shown in place of the figure when the ratio is null. */\n\tunavailable?: React.ReactNode\n}): React.ReactElement {\n\tconst [showParts, setShowParts] = React.useState(false)\n\n\t// Nothing to turn over: if the ratio could not be computed, the parts are what there is.\n\tconst canToggle = value !== null && parts.every((p) => p.value !== null)\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t<div style={ratioRow}>\n\t\t\t\t{showParts && canToggle\n\t\t\t\t\t? (\n\t\t\t\t\t\t<Stack space={1}>\n\t\t\t\t\t\t\t{parts.map((part) => (\n\t\t\t\t\t\t\t\t<Text key={part.label} size={size > 1 ? 2 : size}>\n\t\t\t\t\t\t\t\t\t{(part.format ?? formatCount)(part.value as number)}{' '}\n\t\t\t\t\t\t\t\t\t<Text as=\"span\" size={0} muted>{part.label}</Text>\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</Stack>\n\t\t\t\t\t)\n\t\t\t\t\t: value === null\n\t\t\t\t\t\t? unavailable\n\t\t\t\t\t\t: <Text size={size}>{format(value)}</Text>}\n\t\t\t\t{canToggle && (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tstyle={ratioToggle}\n\t\t\t\t\t\taria-pressed={showParts}\n\t\t\t\t\t\t// Named for what it reveals, not \"toggle\": a reader should know what they will\n\t\t\t\t\t\t// get before pressing, and the label is the only thing telling them.\n\t\t\t\t\t\taria-label={showParts ? 'Show the rate' : 'Show the counts behind this rate'}\n\t\t\t\t\t\tonClick={() => setShowParts((was) => !was)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{showParts ? 'rate' : 'counts'}\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</Stack>\n\t)\n}\n\n/** Props for SortableTable. */\nexport interface SortableTableProps<Row> {\n\tcaption: string\n\tcolumns: Array<SortColumn<Row>>\n\trows: Row[]\n\trowKey: (row: Row) => string\n\t/** Column sorted on first load. Defaults to the server's own ordering. */\n\tinitialSort?: string\n\t/**\n\t * The text a row is matched against when filtering. Omit to render no filter box.\n\t *\n\t * Sorting was the tool's entire interaction budget, which meant a reader who spotted an\n\t * unrelated site's traffic in the table had no way to take it out and see what was left.\n\t */\n\tfilterOn?: (row: Row) => string\n\t/** Placeholder for the filter box, naming what is searched. */\n\tfilterPlaceholder?: string\n\t/** Base filename for the CSV export. Omit to render no export control. */\n\texportName?: string\n\t/** Shown under the table when the server truncated the row set. */\n\ttruncatedNote?: string\n\t/** Allow per-row exclusion even without a filter box. */\n\tonExclude?: boolean\n}\n\n/**\n * A table whose columns sort.\n *\n * Both data tables arrived in one server-chosen order — sessions descending, views descending — so\n * the only question they could answer was the one that order encoded. Sorting by any column turns\n * the same rows into several different questions: which family is tested most relative to views,\n * which source is least attributed, which typeface sells without being looked at.\n *\n * Rows with no value for the active column sort last in both directions. That is deliberate: an\n * unavailable metric is not a zero, and letting it lead an ascending sort would restate exactly the\n * confusion the MetricValue type exists to prevent.\n */\nexport function SortableTable<Row>({\n\tcaption,\n\tcolumns,\n\trows,\n\trowKey,\n\tinitialSort,\n\tfilterPlaceholder,\n\tfilterOn,\n\texportName,\n\ttruncatedNote,\n\tonExclude,\n}: SortableTableProps<Row>): React.ReactElement {\n\tconst [sort, setSort] = React.useState<{ key: string; desc: boolean } | null>(\n\t\tinitialSort ? { key: initialSort, desc: true } : null,\n\t)\n\tconst [query, setQuery] = React.useState('')\n\tconst [excluded, setExcluded] = React.useState<ReadonlySet<string>>(() => new Set())\n\tconst [copied, setCopied] = React.useState<'idle' | 'done' | 'failed'>('idle')\n\t// Off by default: the empty columns are the noise this hides. The control below says how many,\n\t// so the reduction is announced rather than silent.\n\tconst [showEmpty, setShowEmpty] = React.useState(false)\n\t// Held so repeated clicks cannot stack timers and revert the label early, and so the pending\n\t// one is cleared on unmount.\n\tconst copyTimer = React.useRef<number | null>(null)\n\tReact.useEffect(() => () => {\n\t\tif (copyTimer.current !== null) window.clearTimeout(copyTimer.current)\n\t}, [])\n\n\tconst active = sort ? columns.find((c) => c.key === sort.key) : undefined\n\n\t// Filtering and exclusion both happen before sorting, so the ranking is of what is shown.\n\tconst visible = React.useMemo(() => {\n\t\tconst needle = query.trim().toLowerCase()\n\t\treturn rows.filter((row) => {\n\t\t\tif (excluded.has(rowKey(row))) return false\n\t\t\tif (!needle || !filterOn) return true\n\t\t\treturn filterOn(row).toLowerCase().includes(needle)\n\t\t})\n\t}, [rows, query, excluded, filterOn, rowKey])\n\n\t/**\n\t * The columns actually drawn.\n\t *\n\t * Computed from `visible`, not from every row: narrowing the range or filtering the table can\n\t * empty a column, and a column that is empty for what is on screen is noise for what is on\n\t * screen. The first column is always kept — it names the row — as is whatever is being sorted\n\t * on, because folding the sorted column would leave the order unexplained.\n\t */\n\tconst emptyColumns = React.useMemo(\n\t\t() => columns.filter((column, index) =>\n\t\t\tindex > 0 && column.key !== sort?.key && columnIsEmpty(column, visible),\n\t\t),\n\t\t[columns, visible, sort?.key],\n\t)\n\tconst shown = showEmpty || emptyColumns.length === 0\n\t\t? columns\n\t\t: columns.filter((column) => !emptyColumns.includes(column))\n\n\tconst ordered = React.useMemo(() => {\n\t\tconst rows = visible\n\t\tif (!active || !sort) return rows\n\t\tconst copy = [...rows]\n\t\tcopy.sort((a, b) => {\n\t\t\tconst av = active.sortValue(a)\n\t\t\tconst bv = active.sortValue(b)\n\t\t\t// Missing values sink, whichever way the column is pointing.\n\t\t\tif (av === null && bv === null) return 0\n\t\t\tif (av === null) return 1\n\t\t\tif (bv === null) return -1\n\t\t\tconst cmp = typeof av === 'number' && typeof bv === 'number'\n\t\t\t\t? av - bv\n\t\t\t\t: String(av).localeCompare(String(bv))\n\t\t\treturn sort.desc ? -cmp : cmp\n\t\t})\n\t\treturn copy\n\t}, [visible, active, sort])\n\n\tconst toggle = (column: SortColumn<Row>) => {\n\t\tsetSort((current) => {\n\t\t\tif (current?.key !== column.key) return { key: column.key, desc: Boolean(column.numeric) }\n\t\t\treturn { key: column.key, desc: !current.desc }\n\t\t})\n\t}\n\n\t/**\n\t * Copy the visible rows to the clipboard as CSV.\n\t *\n\t * Clipboard rather than a download: the artifact viewer and the Studio both sandbox\n\t * script-initiated downloads, and \"paste into the email you were already writing\" is the actual\n\t * task. Exports what is on screen — filtered, sorted, minus exclusions — because a copy that\n\t * silently differs from the table above it is worse than none.\n\t */\n\tconst copyCsv = async () => {\n\t\t// What is on screen, columns included. A CSV carrying columns the table folded would not be\n\t\t// the thing the button says it copies, and every one of them would be a column of zeros.\n\t\tconst header = shown.map((column) => column.label)\n\t\tconst lines = [header, ...ordered.map((row) => shown.map((column) => {\n\t\t\t// `exportValue` where a column defines one, because `sortValue` is a SORT KEY and is\n\t\t\t// routinely a different thing from what the cell shows: engagement sorts on 0.4318 and\n\t\t\t// displays 43%, revenue sorts on a bare number and displays a currency, and campaign\n\t\t\t// sorts on null where the cell shows the medium it falls back to. The comment below\n\t\t\t// promised a copy of what is on screen and delivered the sort keys instead.\n\t\t\tconst value = column.exportValue ? column.exportValue(row) : column.sortValue(row)\n\t\t\treturn value === null || value === undefined ? '' : String(value)\n\t\t}))]\n\n\t\tconst csv = lines.map((cells) => cells.map(csvCell).join(',')).join('\\n')\n\n\t\ttry {\n\t\t\tawait navigator.clipboard.writeText(csv)\n\t\t\tsetCopied('done')\n\t\t\tif (copyTimer.current !== null) window.clearTimeout(copyTimer.current)\n\t\t\tcopyTimer.current = window.setTimeout(() => setCopied('idle'), 2000)\n\t\t} catch {\n\t\t\t// Reachable, not exotic: navigator.clipboard is undefined in any non-secure context, so\n\t\t\t// a Studio served over plain http or on an internal IP lands here every time. It used\n\t\t\t// to set the flag back to idle, which rendered as the button doing nothing at all.\n\t\t\tsetCopied('failed')\n\t\t\tif (copyTimer.current !== null) window.clearTimeout(copyTimer.current)\n\t\t\tcopyTimer.current = window.setTimeout(() => setCopied('idle'), 4000)\n\t\t}\n\t}\n\n\t// Rows the FILTER hid, and only those. `rows.length - visible.length` counted the excluded ones\n\t// too, because `visible` filters on both — so excluding three rows with no filter text rendered\n\t// \"3 rows hidden · 3 excluded\", and the one line whose job is saying what you are looking at was\n\t// arithmetically wrong.\n\tconst excludedInWindow = rows.filter((row) => excluded.has(rowKey(row))).length\n\tconst hiddenCount = rows.length - visible.length - excludedInWindow\n\t// Counted independently of whether the excluded rows exist in THIS window. Exclusions now\n\t// survive a refetch, so excluding a bot referrer on Quarter and then narrowing to a week where\n\t// it has no sessions left the exclusion armed and the control gone — silently hiding rows on\n\t// the way back out.\n\tconst excludedCount = excluded.size\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t{(filterOn || exportName || emptyColumns.length > 0) && (\n\t\t\t\t<div style={tableControls}>\n\t\t\t\t\t{filterOn && (\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"search\"\n\t\t\t\t\t\t\tvalue={query}\n\t\t\t\t\t\t\tplaceholder={filterPlaceholder ?? 'Filter rows'}\n\t\t\t\t\t\t\taria-label={filterPlaceholder ?? 'Filter rows'}\n\t\t\t\t\t\t\tstyle={filterInput}\n\t\t\t\t\t\t\tonChange={(e) => setQuery(e.currentTarget.value)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t{(hiddenCount > 0 || excludedCount > 0) && (\n\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t{hiddenCount > 0 && <>{hiddenCount} row{hiddenCount === 1 ? '' : 's'} hidden</>}\n\t\t\t\t\t\t\t{excludedCount > 0 && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t{hiddenCount > 0 ? ' · ' : ''}\n\t\t\t\t\t\t\t\t\t{excludedCount} excluded{' '}\n\t\t\t\t\t\t\t\t\t<button type=\"button\" style={inlineLink} onClick={() => setExcluded(new Set())}>\n\t\t\t\t\t\t\t\t\t\trestore\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)}\n\t\t\t\t\t<span style={{ flex: 1 }} />\n\t\t\t\t\t{/* Named with a count, not a bare \"show all\". A reader has to be able to tell the\n\t\t\t\t\t difference between a table with four columns and a table showing four of\n\t\t\t\t\t seven — otherwise this is the tool deciding what the data says. */}\n\t\t\t\t\t{emptyColumns.length > 0 && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tstyle={tableControlButton}\n\t\t\t\t\t\t\taria-pressed={showEmpty}\n\t\t\t\t\t\t\tonClick={() => setShowEmpty((open) => !open)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{showEmpty\n\t\t\t\t\t\t\t\t? `Hide ${emptyColumns.length} empty column${emptyColumns.length === 1 ? '' : 's'}`\n\t\t\t\t\t\t\t\t: `Show ${emptyColumns.length} empty column${emptyColumns.length === 1 ? '' : 's'}`}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t\t{exportName && (\n\t\t\t\t\t\t<button type=\"button\" style={tableControlButton} onClick={() => void copyCsv()}>\n\t\t\t\t\t\t\t{copied === 'done' ? 'Copied' : copied === 'failed' ? 'Could not copy' : 'Copy as CSV'}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t<Card radius={2} tone=\"transparent\" style={tableWrapper}>\n\t\t\t<table style={tableBase}>\n\t\t\t\t<caption style={visuallyHidden}>{caption}</caption>\n\t\t\t\t<thead>\n\t\t\t\t\t<tr>\n\t\t\t\t\t\t{shown.map((column) => {\n\t\t\t\t\t\t\tconst isActive = sort?.key === column.key\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\tkey={column.key}\n\t\t\t\t\t\t\t\t\tscope=\"col\"\n\t\t\t\t\t\t\t\t\tstyle={column.numeric ? headCellNumeric : headCell}\n\t\t\t\t\t\t\t\t\taria-sort={isActive ? (sort?.desc ? 'descending' : 'ascending') : 'none'}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<button type=\"button\" style={sortButton(Boolean(column.numeric))} onClick={() => toggle(column)}>\n\t\t\t\t\t\t\t\t\t\t{column.label}\n\t\t\t\t\t\t\t\t\t\t{/* An arrow, not colour alone, so the sorted column is legible\n\t\t\t\t\t\t\t\t\t\t to anyone. A dot marks the unsorted columns as sortable. */}\n\t\t\t\t\t\t\t\t\t\t<span aria-hidden=\"true\" style={sortMark}>\n\t\t\t\t\t\t\t\t\t\t\t{isActive ? (sort?.desc ? '▼' : '▲') : '↕'}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t})}\n\t\t\t\t\t</tr>\n\t\t\t\t</thead>\n\t\t\t\t<tbody>\n\t\t\t\t\t{ordered.map((row) => {\n\t\t\t\t\t\tconst key = rowKey(row)\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<tr key={key}>\n\t\t\t\t\t\t\t\t{shown.map((column, index) => {\n\t\t\t\t\t\t\t\t\tconst content = column.render(row)\n\t\t\t\t\t\t\t\t\treturn index === 0 ? (\n\t\t\t\t\t\t\t\t\t\t<th key={column.key} scope=\"row\" style={bodyCell}>\n\t\t\t\t\t\t\t\t\t\t\t<span style={firstCell}>\n\t\t\t\t\t\t\t\t\t\t\t\t{content}\n\t\t\t\t\t\t\t\t\t\t\t\t{/* Offered where the row set is the reader's to shape. A chart's\n\t\t\t\t\t\t\t\t\t\t\t\t data table passes neither, because excluding a date there\n\t\t\t\t\t\t\t\t\t\t\t\t would visibly do nothing to the chart above it — an\n\t\t\t\t\t\t\t\t\t\t\t\t affordance that does nothing is worse than none. */}\n\t\t\t\t\t\t\t\t\t\t\t\t{filterOn || onExclude ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t// Per-row exclusion, because the fix for a contaminated\n\t\t\t\t\t\t\t\t\t\t\t\t\t// table is to take the bad row out and see what the\n\t\t\t\t\t\t\t\t\t\t\t\t\t// rest looks like. Rendered on every row rather than\n\t\t\t\t\t\t\t\t\t\t\t\t\t// on hover so it is reachable by keyboard and touch.\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={excludeButton}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={`Exclude ${key}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttitle={`Exclude ${key} from this table`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => setExcluded((current) => new Set(current).add(key))}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t×\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<td key={column.key} style={column.numeric ? bodyCellNumeric : bodyCell}>{content}</td>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t\t{ordered.length === 0 && (\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td colSpan={columns.length} style={bodyCell}>\n\t\t\t\t\t\t\t\t{/* Which emptiness this is. It always blamed the filter, so a site with no\n\t\t\t\t\t\t\t\t orders, or an unconfigured GA4, was told it had filtered its own data\n\t\t\t\t\t\t\t\t away — the reader hunting for a control they never touched. */}\n\t\t\t\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\t\t\t\t{rows.length === 0\n\t\t\t\t\t\t\t\t\t\t? 'Nothing to show for this period.'\n\t\t\t\t\t\t\t\t\t\t: query.trim() || excludedInWindow > 0\n\t\t\t\t\t\t\t\t\t\t\t? 'No rows match this filter.'\n\t\t\t\t\t\t\t\t\t\t\t: 'Nothing to show for this period.'}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t)}\n\t\t\t\t</tbody>\n\t\t\t</table>\n\t\t</Card>\n\n\t\t{truncatedNote && <Text size={0} muted>{truncatedNote}</Text>}\n\t\t</Stack>\n\t)\n}\n\n/**\n * Escape one CSV cell.\n *\n * Quotes the separators, and neutralises a leading `=`, `+`, `-`, `@`, tab or carriage return.\n * That second part matters because source and campaign values are attacker-influenceable from\n * outside: anyone can request the site with `?utm_campaign==HYPERLINK(\"...\")`, GA4 stores it, and\n * pasting the export into a spreadsheet would evaluate it as a live formula.\n */\nfunction csvCell(cell: string): string {\n\tconst neutralised = /^[=+\\-@\\t\\r]/.test(cell) ? `'${cell}` : cell\n\treturn /[\",\\n]/.test(neutralised) ? `\"${neutralised.replace(/\"/g, '\"\"')}\"` : neutralised\n}\n\n/** The controls above a table: filter, hidden-row count, export. */\nconst tableControls: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'center',\n\tgap: SPACE.block,\n\tflexWrap: 'wrap',\n}\n\n/** A section: its heading, then its body, at group spacing. */\nconst sectionBox: React.CSSProperties = { display: 'grid', gap: SPACE.group }\n\n/**\n * A section's body, which owns the rhythm between its own children.\n *\n * This is the line that had `Section` used four times against `SectionTitle`'s eight.\n */\nconst sectionBody: React.CSSProperties = { display: 'grid', gap: SPACE.group }\n\n/** Title and its control on one line, the control pushed to the trailing edge. */\nconst sectionHeader: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'flex-start',\n\tjustifyContent: 'space-between',\n\tgap: 12,\n\t// 0.35, not 0.22. This rule was the faintest line in the file and the only thing separating one\n\t// section from the next — while every button drew at 0.30. A \"Hide\" control outranking the\n\t// boundary between two regions of the panel is the hierarchy upside down.\n\tborderBottom: '1px solid var(--card-border-color, rgba(128,128,128,0.35))',\n\tpaddingBottom: 6,\n}\n\n/** A section the reader is meant to read. */\nconst primaryTitle: React.CSSProperties = { margin: 0, lineHeight: 1.3 }\n\n/**\n * A section the reader consults.\n *\n * Smaller and quieter, and the difference has to be visible at a glance or the hierarchy this\n * component exists to create is just two words in a prop.\n */\nconst secondaryTitle: React.CSSProperties = {\n\tmargin: 0,\n\tlineHeight: 1.3,\n\ttextTransform: 'uppercase',\n\tletterSpacing: '0.07em',\n\topacity: 0.75,\n}\n\n/** The fold control. Reads as a control, not as a heading. */\nconst sectionToggle: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.22))',\n\tborderRadius: 3,\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: '0.8em',\n\tpadding: '2px 9px',\n\tcursor: 'pointer',\n\tflex: '0 0 auto',\n}\n\n/**\n * Two blocks side by side on a wide pane, stacked on a narrow one.\n *\n * The minimum is deliberately large: a table squeezed into half of a narrow Studio pane is worse\n * than the same table full width, so the pair splits only when there is genuinely room for both.\n * Like `cardGrid`, it responds to the PANE rather than the viewport, because the panel is a\n * resizable region inside a Studio that is sometimes inside an iframe.\n */\nexport const splitGrid: React.CSSProperties = {\n\tdisplay: 'grid',\n\tgridTemplateColumns: 'repeat(auto-fit, minmax(min(26rem, 100%), 1fr))',\n\tgap: 24,\n\talignItems: 'start',\n}\n\n/** The axis a capture estimate is plotted on. Relative, so the marks position against it. */\nconst estimateTrack: React.CSSProperties = {\n\tposition: 'relative',\n\theight: 18,\n\tborderRadius: 2,\n\tbackground: mark('bar.track'),\n}\n\n/** One estimate's own caveat, on its own line beneath the counts it qualifies. */\nconst estimateNote: React.CSSProperties = { display: 'block', marginTop: 2, opacity: 0.85 }\n\n/** The 100% reference. A capture rate means nothing without it. */\nconst estimateRule: React.CSSProperties = {\n\tposition: 'absolute',\n\ttop: 0,\n\tbottom: 0,\n\twidth: 1,\n\tbackground: mark('estimate.rule'),\n}\n\n/** How wide the sample leaves this estimate. */\nconst estimateInterval: React.CSSProperties = {\n\tposition: 'absolute',\n\ttop: 6,\n\theight: 6,\n\tborderRadius: 3,\n\tbackground: mark('estimate.interval'),\n}\n\n/** The estimate itself. Its own boundary, which is why the interval may sit under 3:1. */\nconst estimateDot: React.CSSProperties = {\n\tposition: 'absolute',\n\ttop: 4,\n\twidth: 10,\n\theight: 10,\n\tmarginLeft: -5,\n\tborderRadius: '50%',\n\tbackground: mark('estimate.dot'),\n}\n\n\n\n\n\n\n\n\n\n\n/** A ratio and its toggle, sharing a baseline. */\nconst ratioRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'baseline',\n\tgap: SPACE.block,\n\tflexWrap: 'wrap',\n}\n\n/** The control that turns a ratio over. Quiet: it is an affordance, not a finding. */\nconst ratioToggle: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.22))',\n\tborderRadius: 3,\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: '0.72em',\n\tpadding: '1px 7px',\n\tcursor: 'pointer',\n\topacity: 0.75,\n}\n\n/** The filter box. */\nconst filterInput: React.CSSProperties = {\n\tfont: 'inherit',\n\tfontSize: '0.85em',\n\tpadding: '4px 8px',\n\tborderRadius: 3,\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.22))',\n\tbackground: 'transparent',\n\tcolor: 'inherit',\n\tminWidth: 160,\n}\n\n/** A control sitting alongside a table, e.g. the CSV copy. */\nconst tableControlButton: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.22))',\n\tborderRadius: 3,\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: '0.8em',\n\tpadding: '4px 9px',\n\tcursor: 'pointer',\n\twhiteSpace: 'nowrap',\n}\n\n/** An inline text button inside a muted line. */\nconst inlineLink: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: 'none',\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: 'inherit',\n\tpadding: 0,\n\ttextDecoration: 'underline',\n\ttextUnderlineOffset: 2,\n\tcursor: 'pointer',\n}\n\n/** First cell layout: content, with the exclude control pushed to its right. */\nconst firstCell: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'center',\n\tjustifyContent: 'space-between',\n\tgap: 8,\n}\n\n/** The per-row exclude control. Quiet until focused or hovered. */\nconst excludeButton: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: 'none',\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: '1.05em',\n\tlineHeight: 1,\n\topacity: 0.35,\n\tpadding: '0 2px',\n\tcursor: 'pointer',\n\tflex: '0 0 auto',\n}\n\n/** Table scrolls inside its own container, so the panel never scrolls sideways. */\nconst tableWrapper: React.CSSProperties = { overflowX: 'auto', width: '100%' }\n\n/** Base table geometry. */\nconst tableBase: React.CSSProperties = { width: '100%', borderCollapse: 'collapse', minWidth: 420 }\n\n/** Header cell: sticky-feeling separation from the body without a heavy rule. */\nconst headCell: React.CSSProperties = {\n\tpadding: 0,\n\ttextAlign: 'left',\n\tborderBottom: '1px solid var(--card-border-color, rgba(128,128,128,0.22))',\n\twhiteSpace: 'nowrap',\n}\n\nconst headCellNumeric: React.CSSProperties = { ...headCell, textAlign: 'right' }\n\nconst bodyCell: React.CSSProperties = {\n\tpadding: '8px 12px',\n\ttextAlign: 'left',\n\tfontWeight: 400,\n\tborderBottom: '1px solid var(--card-border-color, rgba(128,128,128,0.18))',\n}\n\nconst bodyCellNumeric: React.CSSProperties = {\n\t...bodyCell,\n\ttextAlign: 'right',\n\tfontVariantNumeric: 'tabular-nums',\n}\n\n/** The whole header cell is the control, so the hit area matches what looks clickable. */\nfunction sortButton(numeric: boolean): React.CSSProperties {\n\treturn {\n\t\tappearance: 'none',\n\t\tbackground: 'transparent',\n\t\tborder: 'none',\n\t\tcolor: 'inherit',\n\t\tfont: 'inherit',\n\t\tfontSize: '0.78em',\n\t\tletterSpacing: '0.06em',\n\t\ttextTransform: 'uppercase',\n\t\topacity: 0.7,\n\t\tpadding: '8px 12px',\n\t\twidth: '100%',\n\t\tdisplay: 'flex',\n\t\tgap: 6,\n\t\talignItems: 'center',\n\t\tjustifyContent: numeric ? 'flex-end' : 'flex-start',\n\t\tcursor: 'pointer',\n\t}\n}\n\n/** Sort indicator. */\nconst sortMark: React.CSSProperties = { opacity: 0.7, fontSize: '0.9em' }\n\n/** Present to screen readers, absent visually — the caption names the table without repeating the heading. */\nexport const visuallyHidden: React.CSSProperties = {\n\tposition: 'absolute',\n\twidth: 1,\n\theight: 1,\n\toverflow: 'hidden',\n\tclip: 'rect(0 0 0 0)',\n\twhiteSpace: 'nowrap',\n}\n","/**\n * The series palette, and the rules that keep it honest.\n *\n * Every chart in this package was drawn in `currentColor` at varying alpha, so five series on one\n * chart were five greys separated by dash pattern alone. That is legible in a screenshot and not\n * in use: a reader tracking co-movement between GA4 and Vercel had to keep which-line-is-which in\n * their head while looking at the shape.\n *\n * Three constraints decided these values, and all three are enforced by tests rather than asserted\n * here:\n *\n * 1. ONE SET FOR BOTH THEMES. The Studio ships light and dark and this component is not told which\n * it is in. Rather than swap palettes at a breakpoint we cannot observe, every colour clears\n * 3:1 — the WCAG floor for a graphical object — against BOTH a white card and Sanity's dark\n * card. That is what pins them to mid-lightness.\n *\n * 2. HUE FAMILY MEANS SOURCE. The two GA4 series are deliberately neighbours in the warm range,\n * because they are the same instrument measuring two things; Vercel, orders and revenue each\n * get their own family. So the palette carries a fact rather than just distinguishing rows.\n *\n * 3. COLOUR IS NEVER THE ONLY CHANNEL. Completeness stays encoded as a dash pattern and every\n * value stays readable in the tooltip. The two GA4 hues are close enough to converge for a\n * deuteranope — which is acceptable precisely because dash and label still separate them, and\n * unacceptable for any pair that does not have that second channel.\n */\n\n/** One series colour. The key names what it measures, not what it looks like. */\nexport type SeriesKey = 'vercel' | 'ga4Pageviews' | 'ga4Sessions' | 'orders' | 'revenue'\n\n/**\n * The series colours.\n *\n * Mid-lightness by necessity, not taste — see constraint 1 above. Changing any value here without\n * re-running `palette.test.ts` will silently break a theme nobody is looking at.\n */\nexport const SERIES: Record<SeriesKey, string> = {\n\t/** The complete, server-side count. The reference every other line is read against. */\n\tvercel: '#4C8FD0',\n\t/** GA4's pageviews — the like-for-like counterpart to Vercel's. */\n\tga4Pageviews: '#DD6B3F',\n\t/** GA4's sessions. Warm, like its sibling above: same instrument, different unit. */\n\tga4Sessions: '#BC861F',\n\t/** Orders, from the foundry's own records. Exact. */\n\torders: '#2E9E6B',\n\t/** Revenue, same source, different unit. */\n\trevenue: '#BF5D9B',\n}\n\n/**\n * Alpha for a filled region drawn in a series colour.\n *\n * Regions sit under lines and behind labels, so they are held well below the stroke's weight. The\n * shortfall region is the one exception the panel makes, and it says so where it draws it.\n */\nexport const REGION_ALPHA = 0.18\n\n/**\n * A series colour at a given alpha, as an `rgba()` string.\n *\n * SVG `fill-opacity` would be simpler, but it multiplies with any opacity already on the parent\n * group and these fills sit inside groups that carry their own. Baking the alpha into the colour\n * keeps a region's weight the same wherever it is mounted.\n *\n * @param key - which series\n * @param alpha - 0 to 1\n */\nexport function seriesFill(key: SeriesKey, alpha: number = REGION_ALPHA): string {\n\treturn withAlpha(SERIES[key], alpha)\n}\n\n/**\n * Any hex at a given alpha, as an `rgba()` string.\n *\n * @param hex - the colour\n * @param alpha - 0 to 1\n */\nexport function withAlpha(hex: string, alpha: number): string {\n\tconst r = parseInt(hex.slice(1, 3), 16)\n\tconst g = parseInt(hex.slice(3, 5), 16)\n\tconst b = parseInt(hex.slice(5, 7), 16)\n\treturn `rgba(${r}, ${g}, ${b}, ${alpha})`\n}\n\n/**\n * The comparison identity: every figure, mark and control that refers to the OTHER window.\n *\n * Deliberately outside `SERIES`, because it names a RELATIONSHIP rather than a source. A delta, the\n * ghost line behind a series, and the basis toggle that chooses between \"previous period\" and \"last\n * year\" are one idea, and before this they were three greys at three opacities plus an underline on\n * one tone — so nothing on screen said they were related, which is exactly what a reader asked for.\n *\n * Hue 255 sits in the widest gap in the series ring (vercel 210 to revenue 322). Its nearest\n * neighbour under deuteranopia is vercel at dE 12.2 — wider than the ga4Pageviews/ga4Sessions pair\n * this palette already ships on purpose, and 57 from the neutral grey it replaces, which is what\n * lets it read as an identity rather than as more muting.\n *\n * 4.29:1 on white and 4.28:1 on Sanity's dark card. For this pair of grounds 4.285 is the ceiling\n * any single colour can reach on both sides at once, so this is the balance point, not a\n * compromise. It only holds at FULL ALPHA: at 0.9 it is 3.6, at 0.7 it is 2.6, at 0.55 it is 2.1.\n * That is why the opacity ladder that used to carry emphasis had to go rather than be tinted.\n */\nexport const COMPARISON = '#8368D4'\n\n/**\n * The comparison colour as TEXT, which needs a higher floor than a mark does.\n *\n * `COMPARISON` is balanced to sit as well as possible on both grounds at once, and 4.285:1 is the\n * arithmetic ceiling for that: make it lighter and the white card fails, darker and the dark card\n * does. That clears the 3:1 a graphical object needs, and it is what every comparison MARK uses.\n *\n * But the identity is also worn by the deltas, their baselines, the toggle's label and the sentence\n * naming the window — all of them small text, which WCAG 1.4.3 holds to 4.5:1. No single colour can\n * reach that on both grounds, so text gets a pair, one per ground, and the browser picks.\n *\n * The pair is selected off Sanity's own `data-scheme` attribute. An earlier version used\n * `light-dark()` on `:root` — which measured correctly in isolation and was wrong in the Studio,\n * because Sanity sets `color-scheme` on the CARD, not on the root. At `:root` the used scheme is\n * `normal`, `light-dark()` resolves to its light half, and a dark Studio would have rendered the\n * light colour at 3.09:1 — worse than the balanced colour it replaced. Verified in the browser\n * rather than reasoned about: the outermost Card carries `data-scheme=\"dark\"` with background\n * #13141b, and `data-scheme=\"light\"` with #ffffff, which are exactly the two grounds this module\n * measures against.\n *\n * Every use site keeps the balanced colour as its `var()` fallback, so if that attribute ever\n * changes the identity degrades to where it started rather than disappearing.\n */\nexport const COMPARISON_TEXT = `var(--vi-comparison, ${COMPARISON})`\n\n/** The text pair. Same hue family as COMPARISON, so the identity survives the theme switch. */\nexport const COMPARISON_ON_LIGHT = '#6A4FC4'\nexport const COMPARISON_ON_DARK = '#A594E8'\n\n/**\n * The stylesheet that defines `--vi-comparison`, rendered once by the tool.\n *\n * A custom property rather than an inline style because an inline style cannot carry a fallback:\n * React sets one value per key, so there would be no way to say \"this colour, or that one if the\n * ground is dark\" without knowing the theme at render time, which this package deliberately does\n * not.\n */\nexport const COMPARISON_STYLE = `\n:root { --vi-comparison: ${COMPARISON}; }\n[data-scheme=\"light\"] { --vi-comparison: ${COMPARISON_ON_LIGHT}; }\n[data-scheme=\"dark\"] { --vi-comparison: ${COMPARISON_ON_DARK}; }\n`.trim()\n\n/** The grounds a colour has to work on: Sanity's light card and its dark one. */\nexport const GROUNDS = { light: '#ffffff', dark: '#13141b' } as const\n\n/** Relative luminance, per WCAG 2.1. */\nexport function luminance(hex: string): number {\n\tconst channel = (raw: number): number => {\n\t\tconst c = raw / 255\n\t\treturn c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4\n\t}\n\tconst r = channel(parseInt(hex.slice(1, 3), 16))\n\tconst g = channel(parseInt(hex.slice(3, 5), 16))\n\tconst b = channel(parseInt(hex.slice(5, 7), 16))\n\treturn 0.2126 * r + 0.7152 * g + 0.0722 * b\n}\n\n/** Contrast ratio between two colours, per WCAG 2.1. Symmetric, 1 to 21. */\nexport function contrast(a: string, b: string): number {\n\tconst la = luminance(a)\n\tconst lb = luminance(b)\n\treturn (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)\n}\n\n// ---------------------------------------------------------------------------\n// The mark registry\n// ---------------------------------------------------------------------------\n\n/**\n * What a mark is for, which decides whether 3:1 applies to it.\n *\n * `data` — carries a value the reader must perceive: a line, a bar, a stem. WCAG 1.4.11 applies.\n * `fill` — a region tinted behind or beneath data. Deliberately light so what it covers stays\n * readable, so it CANNOT carry 3:1 and must name a `boundary` mark that does.\n * `furniture` — rails, grids, baselines. Not a value; exempt, but still drawn visibly.\n */\nexport type MarkRole = 'data' | 'fill' | 'furniture'\n\n/** One drawn mark: the colour, the alpha it is ACTUALLY drawn at, and what it is for. */\nexport interface Mark {\n\tkey: SeriesKey | 'neutral' | 'comparison'\n\t/** The opacity the renderer applies. This is the number the contrast test composites with. */\n\talpha: number\n\trole: MarkRole\n\t/** For a `fill`: the `data` mark whose stroke makes this region's extent perceivable. */\n\tboundary?: string\n\t/** Why this mark exists, for the next person reading the test output. */\n\tnote: string\n}\n\n/**\n * Every mark this package draws.\n *\n * THIS IS THE POINT OF THIS MODULE. The previous version exported five hex constants, asserted 3:1\n * against them, and left each renderer free to multiply by whatever opacity it liked — so the test\n * validated a colour that was never drawn, and the shipped shortfall region sat at 1.40:1 while a\n * green suite said the palette was sound. Twenty-nine tests, mutation-checked, all measuring a\n * constant.\n *\n * Renderers read their alpha from here. The test walks this registry and composites each entry over\n * both grounds. The two cannot drift, because they are the same object.\n */\nexport const MARKS: Record<string, Mark> = {\n\t'chart.line': { key: 'vercel', alpha: 1, role: 'data', note: 'A source series in the timeline' },\n\t'chart.lossyLine': { key: 'ga4Pageviews', alpha: 1, role: 'data', note: 'The lossier source, dashed' },\n\t'chart.coverage': { key: 'ga4Sessions', alpha: 1, role: 'data', note: 'Share one source saw of another' },\n\t'chart.stem': { key: 'orders', alpha: 1, role: 'data', note: 'One day that had orders' },\n\t'chart.zeroTick': { key: 'orders', alpha: 1, role: 'data', note: 'A day MEASURED at zero — the package\\'s founding distinction' },\n\t// Renamed from `chart.regionEdge`. It was the boundary of a filled region; the region is gone —\n\t// its width was the absolute gap, which under a flat coverage rate is the traffic curve scaled\n\t// down, so it widened on busy days with no change in the instrument. This is now simply the\n\t// lossier source's own line, which is what the collapse detector and the spoken summary read.\n\t'chart.lossy': { key: 'ga4Pageviews', alpha: 1, role: 'data', note: 'The lossier source\\'s own level, dashed beneath the complete one' },\n\t'chart.ghost': { key: 'comparison', alpha: 1, role: 'data', note: 'The same window last period, behind everything' },\n\t'chart.grid': { key: 'neutral', alpha: 0.18, role: 'furniture', note: 'Grid rules' },\n\t'bar.fill': { key: 'vercel', alpha: 1, role: 'data', note: 'Every proportion, comparison and funnel bar' },\n\t'bar.track': { key: 'neutral', alpha: 0.12, role: 'furniture', note: 'The rail a bar sits in' },\n\t// Neither a source nor money. It was drawn in the REVENUE hue, so a magenta bar between funnel\n\t// rungs read as takings; GA4's hue was rejected for the opposite reason, since that hue means\n\t// \"this instrument measured it\" everywhere else. People who did not continue are an absence, so\n\t// they take the neutral — which clears 4.00:1 light and 4.59:1 dark at full alpha.\n\t'bar.lost': { key: 'neutral', alpha: 1, role: 'data', note: 'People who did not continue past a funnel rung' },\n\t'bar.seen': { key: 'ga4Pageviews', alpha: 1, role: 'data', note: 'The share of a complete count that the lossy source saw' },\n\t'estimate.dot': { key: 'ga4Pageviews', alpha: 1, role: 'data', note: 'One independent estimate of how much GA4 sees' },\n\t'estimate.interval': { key: 'ga4Pageviews', alpha: 0.35, role: 'fill', boundary: 'estimate.dot', note: 'How wide the sample leaves that estimate — the dot is its own boundary' },\n\t'estimate.rule': { key: 'neutral', alpha: 0.45, role: 'furniture', note: 'The 100% reference a capture rate is read against' },\n\t// A segment per colour rather than a dash pattern per segment. Dash was the ONLY channel\n\t// separating these lines, and it is the one channel `preserveAspectRatio=\"none\"` distorted —\n\t// so two segments could be drawn identically on a wide pane. The keys are reused from the\n\t// series palette because they already clear 3:1 on both grounds; they carry no source meaning\n\t// here, and the legend states which is which.\n\t'survival.line': { key: 'vercel', alpha: 1, role: 'data', note: 'The first audience segment falling through a funnel' },\n\t'survival.line.2': { key: 'ga4Pageviews', alpha: 1, role: 'data', note: 'The second audience segment' },\n\t'survival.line.3': { key: 'orders', alpha: 1, role: 'data', note: 'The third audience segment' },\n\t'survival.line.4': { key: 'revenue', alpha: 1, role: 'data', note: 'The fourth audience segment' },\n\t'survival.grid': { key: 'neutral', alpha: 0.18, role: 'furniture', note: 'The 50% and 100% references a survival line is read against' },\n}\n\n/**\n * The rgba a mark is drawn in.\n *\n * @param name - a key of MARKS\n */\nexport function mark(name: string): string {\n\tconst m = MARKS[name]\n\tif (!m) throw new Error(`Unknown mark: ${name}`)\n\treturn withAlpha(baseHex(m.key), m.alpha)\n}\n\n/**\n * The hex a mark key resolves to, before its alpha is applied.\n *\n * Exported so the contrast test composites the SAME colour the renderer draws. It had its own copy\n * of this mapping; the moment a third kind of key existed, that copy returned undefined.\n *\n * @param key - a series, the neutral, or the comparison identity\n */\nexport function baseHex(key: SeriesKey | 'neutral' | 'comparison'): string {\n\tif (key === 'neutral') return NEUTRAL\n\tif (key === 'comparison') return COMPARISON\n\treturn SERIES[key]\n}\n\n/** The grey used for furniture that belongs to no series. */\nexport const NEUTRAL = '#7f7f7f'\n\n/**\n * Composite a colour at an alpha over a ground, so contrast can be measured as DRAWN.\n *\n * @param hex - the mark colour\n * @param alpha - the opacity the renderer applies\n * @param ground - what it is drawn on top of\n */\nexport function composite(hex: string, alpha: number, ground: string): string {\n\tconst mix = (at: number): number => Math.round(\n\t\tparseInt(hex.slice(at, at + 2), 16) * alpha + parseInt(ground.slice(at, at + 2), 16) * (1 - alpha),\n\t)\n\tconst hexOf = (n: number) => n.toString(16).padStart(2, '0')\n\treturn `#${hexOf(mix(1))}${hexOf(mix(3))}${hexOf(mix(5))}`\n}\n","/** Shared contract types for the Studio plugin and the API-route handler it calls. */\n\n// ---------------------------------------------------------------------------\n// Metric values\n// ---------------------------------------------------------------------------\n\n/**\n * Why a metric has no usable number. Kept distinct rather than collapsed into one flag, because\n * the UI must say different things: \"this site never tracked it\" is permanent, \"not yet at this\n * date\" resolves as time passes, and \"GA4 withheld it\" means the number exists but is hidden.\n */\nexport type UnavailableReason =\n\t/** The event has never been instrumented on this site. Permanent for historical ranges. */\n\t| 'not_instrumented'\n\t/** Instrumented, but the requested range predates the cutover date. */\n\t| 'before_cutover'\n\t/** GA4 suppressed the row for privacy thresholding. A real number exists; we may not see it. */\n\t| 'suppressed'\n\t/** The event exists in the code but stopped reaching GA4 for this period. Not a real zero. */\n\t| 'outage'\n\t/** The upstream source errored or timed out. Retryable, unlike the others. */\n\t| 'source_error'\n\t/** The metric is not defined for this site at all (e.g. a flow that does not exist there). */\n\t| 'not_applicable'\n\t/**\n\t * The site's API route is older than this figure — the data exists, this deployment cannot\n\t * return it. Distinct from `not_applicable`, which says the figure never applies here.\n\t */\n\t| 'route_outdated'\n\n/**\n * A metric that may legitimately have no value.\n *\n * The whole point of this union is that there is no way to read a number without first handling\n * the absent case — a missing metric can never silently coerce to 0 and be charted as a real\n * trough. `partial` carries a number that is only valid for part of the requested range.\n */\nexport type MetricValue =\n\t| { status: 'ok'; value: number }\n\t| { status: 'partial'; value: number; coveredFrom: string; note: string }\n\t/**\n\t * A figure inferred rather than counted — a GA4 count grossed up by its measured capture rate.\n\t *\n\t * Carries an interval, not just a point, because the capture rate is itself estimated from\n\t * several ratios that rarely agree exactly. Its own variant so it can never be read as a\n\t * measurement: the union forces every render site to handle it, which is the same reason\n\t * `unavailable` is a variant rather than a null.\n\t */\n\t| { status: 'estimated'; value: number; low: number; high: number; basis: string }\n\t| { status: 'unavailable'; reason: UnavailableReason; detail?: string }\n\n/**\n * Construct an inferred metric.\n *\n * @param value - the point estimate\n * @param low - the low end of the plausible range\n * @param high - the high end\n * @param basis - how it was inferred, in words the panel shows directly\n */\nexport function estimated(value: number, low: number, high: number, basis: string): MetricValue {\n\treturn { status: 'estimated', value, low, high, basis }\n}\n\n/** Construct an available metric. */\nexport function ok(value: number): MetricValue {\n\treturn { status: 'ok', value }\n}\n\n/** Construct an unavailable metric. */\nexport function unavailable(reason: UnavailableReason, detail?: string): MetricValue {\n\treturn { status: 'unavailable', reason, detail }\n}\n\n/**\n * Construct a metric covering only part of the requested range.\n *\n * @param value - the number for the covered portion\n * @param coveredFrom - ISO date from which the number is valid\n * @param note - short human-readable reason, shown next to the figure\n */\nexport function partial(value: number, coveredFrom: string, note: string): MetricValue {\n\treturn { status: 'partial', value, coveredFrom, note }\n}\n\n/**\n * Read a metric's number, or `null` when there isn't a usable one.\n * Use at render time so the absent case has to be handled explicitly.\n */\nexport function valueOrNull(metric: MetricValue | undefined): number | null {\n\t// Undefined is treated as absent, not as a type error to crash on. A site's API route can be\n\t// older than the Studio bundle reading it — the package documents that as its normal state\n\t// between a publish and a redeploy — so every field is optional in practice whatever the type\n\t// says, and a helper that throws on one takes the whole panel down.\n\treturn !metric || metric.status === 'unavailable' ? null : metric.value\n}\n\n// ---------------------------------------------------------------------------\n// Date ranges\n// ---------------------------------------------------------------------------\n\n/** The range granularities offered in the UI. */\n/**\n * A named range, or `custom` for an explicit start and end supplied by the caller.\n *\n * The named ones are trailing windows ending today, not calendar periods — `quarter` is the last\n * 91 days, not Q3. `custom` exists so a reader can look at a past period at all, which none of the\n * trailing windows can express.\n */\nexport type RangeKey = 'week' | 'month' | 'quarter' | 'year' | 'custom'\n\n/**\n * A resolved date range. Both bounds are inclusive ISO `YYYY-MM-DD` dates in `timezone`.\n *\n * `timezone` is carried explicitly because GA4 buckets by the property's configured timezone,\n * Vercel reports in UTC, and Sanity `_createdAt` is UTC — comparing them without a single declared\n * anchor silently shifts events across day and week boundaries.\n */\nexport interface DateRange {\n\tkey: RangeKey\n\tstart: string\n\tend: string\n\ttimezone: string\n}\n\n// ---------------------------------------------------------------------------\n// Reports\n// ---------------------------------------------------------------------------\n\n/** The report names this package serves. Used as a strict allow-list, never as dynamic dispatch. */\nexport const REPORT_NAMES = [\n\t'measurement-health',\n\t'acquisition',\n\t'journey',\n\t'typeface-interest',\n\t'diagnostics',\n] as const\n\nexport type ReportName = (typeof REPORT_NAMES)[number]\n\n/** Narrow an untrusted string to a known report name. */\nexport function isReportName(value: unknown): value is ReportName {\n\treturn typeof value === 'string' && (REPORT_NAMES as readonly string[]).includes(value)\n}\n\n/** Which upstreams a report consulted, and how each fared. */\nexport type SourceStatus =\n\t| { status: 'ok' }\n\t| { status: 'unconfigured' }\n\t| { status: 'error'; message: string }\n\n/** Upstream data sources this package can read. */\nexport type SourceName = 'ga4' | 'vercel' | 'sanity' | 'mailchimp'\n\n/**\n * Envelope around every report response.\n *\n * `sources` is mandatory rather than optional because partial failure is the normal case here,\n * not the exception: a panel comparing three sources must be able to distinguish \"this source\n * reported zero\" from \"this source never answered\", which a bare payload cannot express.\n */\nexport interface ReportEnvelope<T> {\n\treport: ReportName\n\trange: DateRange\n\t/** Per-source outcome. A report may succeed overall while one source is unconfigured. */\n\tsources: Partial<Record<SourceName, SourceStatus>>\n\t/** Caveats to surface in the UI, e.g. GA4 sampling or an instrumentation cutover in range. */\n\tnotices: string[]\n\tdata: T\n\t/**\n\t * The same report over the immediately preceding equivalent window.\n\t *\n\t * A bare level is close to meaningless at these volumes: \"389 sessions\" is neither good nor bad\n\t * without last week beside it, and the 24 August collapse would have announced itself on every\n\t * panel as a delta while going unnoticed for over a week as a level. Absent when the comparison\n\t * could not be computed, because a missing comparison must not read as \"no change\". Absent\n\t * means the prior run threw, or this report does not draw deltas; there is deliberately no\n\t * cutover check here, and an earlier version of this comment claimed one existed.\n\t */\n\tcomparison?: {\n\t\trange: DateRange\n\t\tdata: T\n\t\t/**\n\t\t * True when the CURRENT window still has unprocessed days while the comparison window is\n\t\t * complete, which biases every GA4-derived delta downwards.\n\t\t */\n\t\tprovisional: boolean\n\t\t/** Which baseline the reader chose: the preceding window, or the same window a year back. */\n\t\tbasis?: 'previous-period' | 'same-period-last-year'\n\t}\n}\n\n/** Error shape returned by the handler. Never leaks upstream error text to the browser. */\nexport interface ReportError {\n\terror: string\n\treport?: string\n}\n","/**\n * The report panels.\n *\n * Every panel renders a table or labelled bars rather than a chart, and every one surfaces its own\n * caveats inline. Tables are the accessible representation as well as the visual one, so there is\n * no separate \"view as data\" toggle that could drift out of sync with what is displayed.\n *\n * Every array these panels read is accessed defensively, and that is not paranoia about the server.\n * The Studio bundle and the site's API route are separate deployments on separate schedules: a\n * Studio can be upgraded while its route still runs an older package, and then a field added in the\n * newer version simply is not in the response. On 2026-09-01 a Studio on 0.8.0 read `data.daily`\n * from a route still on 0.6.2 and took the whole tool down with \"Cannot read properties of\n * undefined\". A panel must degrade to showing less, never to a stack trace.\n */\n\nimport React from 'react'\nimport { Badge, Card, Flex, Heading, Label, Stack, Text } from '@liiift-studio/sanity-ui-compat'\nimport { ChartData, ContainmentBar, Delta, SPACE, panelStack, stackBlock, stackGroup, formatDay, LicenceLadder, EstimateDotPlot, RatioFigure, FunnelChart, MetricFigure, NoticeList, MIN_DELTA_BASE, MIN_RATE_DENOMINATOR, ProportionChart, Section, SectionTitle, isContainment, SortableTable, formatCount, formatMoney, formatPercent, splitGrid } from './Figure'\nimport { CrossSourceTimeline } from './CrossSourceTimeline'\nimport { SEND_WINDOW_DAYS } from '../core/ranges'\nimport { describeRank, weeklyRank } from '../core/rank'\nimport { samplingInterval } from '../core/capture'\nimport type {\n\tAcquisitionData,\n\tCheckStatus,\n\tSourceRow,\n\tTypefaceInterestRow,\n\tDiagnosticReport,\n\tCrossSourceDay,\n\tEmailCampaign,\n\tJourneyData,\n\tJourneySegment,\n\tJourneyStep,\n\tLandingPage,\n\tMeasurementHealthData,\n\tTypefaceInterestData,\n} from '../reportData'\nimport { ok, partial, unavailable, type MetricValue } from '../types'\n\n/**\n * Sortable value for a metric, or null when there is nothing to sort on.\n *\n * An unavailable metric is not a zero — sorting it as one would put \"never measured\" at the bottom\n * of an ascending column beside genuine zeros, which is the confusion MetricValue exists to prevent.\n * SortableTable sinks nulls in both directions instead.\n */\nfunction metricSortValue(metric: MetricValue | undefined): number | null {\n\tif (!metric || metric.status === 'unavailable') return null\n\treturn Number.isFinite(metric.value) ? metric.value : null\n}\n\n/**\n * Read a metric field that may not exist in the response at all.\n *\n * The Studio bundle and the site's API route deploy on separate rails, so a Studio can be several\n * versions ahead of the route it calls — Darden's Studio was on 0.13.1 while its production route\n * still resolved 0.6.x. Every field added since then arrives as `undefined`, and reading `.status`\n * on it threw inside a sort comparator, which meant the panel rendered nothing at all rather than\n * rendering without one column.\n *\n * Array fields were already guarded with `?? []`. Metric fields were not, and this is the guard\n * they needed.\n *\n * @param metric - the possibly-absent field\n * @param detail - what to say when it is absent\n */\nfunction metricOr(metric: MetricValue | undefined, detail: string): MetricValue {\n\tif (metric) return metric\n\treturn { status: 'unavailable', reason: 'route_outdated', detail }\n}\n\n/** What an absent field means: the route predates the field, not the site lacking the data. */\n// Phrased for the reader, who does not deploy anything. The action belongs to whoever maintains\n// the site, and naming it as theirs rather than issuing it as an instruction is the difference\n// between information and a task nobody in the room can do.\nconst OLDER_ROUTE = 'This site\\u2019s analytics route is older than this figure — it will appear after the site is next deployed.'\n\n\n\n\n/**\n * A usable number, or null.\n *\n * Narrower than a `!== null` check, which lets `undefined` through — and `undefined` is exactly\n * what an older API route sends for a field added since it was deployed. `formatPercent(undefined)`\n * then renders \"NaN%\" in the largest type on the panel.\n */\nfunction finiteOrNull(value: number | null | undefined): number | null {\n\treturn typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/** Identity for a source row: every dimension the report groups by. */\nfunction acquisitionRowKey(row: SourceRow): string {\n\treturn [row.source, row.channel, row.medium ?? '', row.campaign ?? ''].join(' \\u203a ')\n}\n\n/** A figure with its period-over-period delta alongside, wrapping on a narrow pane. */\nconst figureRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'baseline',\n\tgap: SPACE.block,\n\tflexWrap: 'wrap',\n}\n\n/** Referrer links, marked as links without shouting. */\n\n\n\nconst sourceLink: React.CSSProperties = {\n\tcolor: 'inherit',\n\ttextDecoration: 'underline',\n\ttextUnderlineOffset: 3,\n\ttextDecorationThickness: 1,\n}\n\n/**\n * Whether a GA4 source value is a real host worth linking.\n *\n * GA4's own buckets — (direct), (not set), (none) — look like sources in the table and are not\n * destinations. Linking them would produce a dead https://(direct) that erodes trust in every other\n * link on the page.\n */\nfunction isLinkableHost(source: string): boolean {\n\treturn /^[a-z0-9-]+(\\.[a-z0-9-]+)+$/i.test(source)\n}\n\n/**\n * Card row that reflows by available width rather than by viewport breakpoints.\n *\n * The Studio panel is a resizable pane inside a Studio inside, sometimes, an iframe — its width has\n * little to do with the viewport, so `columns={[1, 3]}` was answering the wrong question. auto-fit\n * with a minimum also survives the compat shim falling back to a plain div, where a column token\n * would mean nothing.\n */\nconst cardGrid: React.CSSProperties = {\n\tdisplay: 'grid',\n\tgridTemplateColumns: 'repeat(auto-fit, minmax(min(14rem, 100%), 1fr))',\n\tgap: SPACE.group,\n}\n\n/**\n * How many pageviews GA4 did not see on a day, or null when either side is unmeasured.\n *\n * Null rather than zero: a day one source did not report is not a day they agreed. Negative is\n * kept, not clamped — GA4 counting MORE than Vercel is a real and diagnosable state (a tag firing\n * twice), and hiding it behind a floor of zero would make the two failure modes look identical.\n *\n * @param day - one day of the cross-source series\n */\nexport function gapOf(day: { vercelPageviews: number | null; ga4Pageviews: number | null }): number | null {\n\tif (day.vercelPageviews === null || day.ga4Pageviews === null) return null\n\treturn day.vercelPageviews - day.ga4Pageviews\n}\n\n/**\n * The fraction of a day's pageviews GA4 saw, or null when it cannot be computed.\n *\n * Null on a zero denominator as well as on a missing measurement: no traffic means no coverage\n * figure, and 0/0 rendered as 0% would put the quietest days at the top of a sort meant to find\n * the worst ones.\n *\n * @param day - one day of the cross-source series\n */\nexport function coverageOf(day: { vercelPageviews: number | null; ga4Pageviews: number | null }): number | null {\n\tif (day.vercelPageviews === null || day.ga4Pageviews === null || day.vercelPageviews <= 0) return null\n\treturn day.ga4Pageviews / day.vercelPageviews\n}\n\n/**\n * Overview — the five-minute read.\n *\n * Revenue, orders, the mailing list, the cross-source timeline and the campaign table all used to\n * live on a tab called \"Measurement health\", whose own blurb announced it was about how much of\n * reality each source sees. Four of the five questions a foundry owner opens this for were filed\n * under plumbing, on tab four, behind a door labelled for the instrument. This is the same data,\n * first, under a heading that says what it is.\n */\nexport function OverviewPanel({ data, previous, onBrush }: {\n\tdata: MeasurementHealthData\n\tprevious?: MeasurementHealthData\n\t/** Narrow every panel to a span dragged on the timeline. */\n\tonBrush?: (start: string, end: string) => void\n}): React.ReactElement {\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t<Verdict data={data} previous={previous} />\n\n\t\t\t<div style={cardGrid}>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Revenue</Label>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t{/* One render path, told what the number is. The card used to take the currency\n\t\t\t\t\t\t\t branch only on `ok` and fall through to MetricFigure — and therefore to the\n\t\t\t\t\t\t\t COUNT formatter — for everything else. Revenue here is permanently partial,\n\t\t\t\t\t\t\t because most orders predate the amount field, so the branch never fired and\n\t\t\t\t\t\t\t the tab's headline money rendered as a bare \"12,346\". */}\n\t\t\t\t\t\t\t<MetricFigure\n\t\t\t\t\t\t\t\tmetric={metricOr(data.revenue, OLDER_ROUTE)}\n\t\t\t\t\t\t\t\tlabel=\"Revenue\"\n\t\t\t\t\t\t\t\tunit=\"money\"\n\t\t\t\t\t\t\t\tcurrency={data.currency ?? null}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Delta current={metricSortValue(data.revenue)} previous={metricSortValue(previous?.revenue)} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/* The rank, beside the change rather than instead of it.\n\t\t\t\t\t\t\n\t\t\t\t\t\t A percentage on these counts is the part that misleads — seven orders\n\t\t\t\t\t\t against four is \"+75%\" and moves twenty-five points on one more sale —\n\t\t\t\t\t\t but the change is still what a reader looks for first, so it stays. The\n\t\t\t\t\t\t rank is what tells them whether the week was normal, which the percentage\n\t\t\t\t\t\t never could. Exact quantities only: ranking a GA4 figure would rank the\n\t\t\t\t\t\t instrument's mood alongside the business. */}\n\t\t\t\t\t\t{/* Gated on the SAME condition as the timeline's revenue row, which is the point.\n\t\t\t\t\t\t\n\t\t\t\t\t\t `crossSource[].revenue` is `revenueByDate[date] ?? 0`, and a day whose orders\n\t\t\t\t\t\t all lacked an amount has no key — so it is summed as a zero. The chart below\n\t\t\t\t\t\t refuses to draw that series for exactly this reason; the rank line beside it\n\t\t\t\t\t\t ranked weeks off the same numbers and announced \"best of the last 13 weeks\".\n\t\t\t\t\t\t One chart declining to show a series while a sentence four inches up draws a\n\t\t\t\t\t\t conclusion from it is the disagreement this whole review kept finding. */}\n\t\t\t\t\t\t{revenueCoversEveryOrder(data) && rankLine(data.crossSource, (d) => d.revenue) && (\n\t\t\t\t\t\t\t<Text size={0} muted>{rankLine(data.crossSource, (d) => d.revenue)}</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Orders</Label>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.orders, OLDER_ROUTE)} label=\"Orders\" />\n\t\t\t\t\t\t\t<Delta current={metricSortValue(data.orders)} previous={metricSortValue(previous?.orders)} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{rankLine(data.crossSource, (d) => d.orders) && (\n\t\t\t\t\t\t\t<Text size={0} muted>{rankLine(data.crossSource, (d) => d.orders)}</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Traffic</Label>\n\t\t\t\t\t\t{/* The verdict asserts a traffic move; without this card the reader was told a\n\t\t\t\t\t\t number changed and shown no number. Vercel's, because it is the complete\n\t\t\t\t\t\t one — GA4's sessions differ from it by 86% on Darden. */}\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.vercelPageviews, OLDER_ROUTE)} label=\"Pageviews\" />\n\t\t\t\t\t\t\t<Delta current={metricSortValue(data.vercelPageviews)} previous={metricSortValue(previous?.vercelPageviews)} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{data.vercelPageviews && data.vercelPageviews.status !== 'unavailable' && (\n\t\t\t\t\t\t\t<Text size={0} muted>Pageviews, from Vercel’s own counter.</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t{/* \"Total\", in the label, because this card sits in a row with Revenue, Orders and\n\t\t\t\t\t\t Traffic — all scoped to the selected range — while it alone is Mailchimp's\n\t\t\t\t\t\t current all-time count. On a Week range the growth line beneath is withheld\n\t\t\t\t\t\t (Mailchimp reports growth by calendar month), so the card was a lifetime\n\t\t\t\t\t\t figure standing unlabelled beside three period figures. */}\n\t\t\t\t\t\t<Label size={1} muted>Mailing list, total</Label>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.audience, OLDER_ROUTE)} label=\"Mailing list members\" />\n\t\t\t\t\t\t\t{/* audienceGrowth, not a delta between windows. `member_count` is Mailchimp's\n\t\t\t\t\t\t\t CURRENT total and carries no date filter, so it is identical in this\n\t\t\t\t\t\t\t envelope and the comparison one — the delta was structurally always\n\t\t\t\t\t\t\t \"no change\", teaching the reader their list never moves, while the real\n\t\t\t\t\t\t\t net change was computed server-side and rendered nowhere. */}\n\t\t\t\t\t\t\t{(() => {\n\t\t\t\t\t\t\t\t// Rendered directly. audienceGrowth is ALREADY the net change, so passing\n\t\t\t\t\t\t\t\t// it to Delta with a baseline of 0 printed \"new from 0\" on an established\n\t\t\t\t\t\t\t\t// list of four thousand people.\n\t\t\t\t\t\t\t\tconst growth = metricSortValue(data.audienceGrowth)\n\t\t\t\t\t\t\t\tif (growth === null) {\n\t\t\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t\t\t * Absent SAYS why, rather than rendering nothing.\n\t\t\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t\t\t * `metricSortValue` returns null for any unavailable metric, so the reason\n\t\t\t\t\t\t\t\t\t * the server took care to write — \"Mailchimp reports list growth by\n\t\t\t\t\t\t\t\t\t * calendar month, so this range has no start figure\" — was discarded. And\n\t\t\t\t\t\t\t\t\t * the start figure is only fetched when the range begins on the first of a\n\t\t\t\t\t\t\t\t\t * month, which no preset range does. So on every range the card showed a\n\t\t\t\t\t\t\t\t\t * bare total and nothing else, forever, and a foundry whose largest asset\n\t\t\t\t\t\t\t\t\t * is its list would conclude the tool does not do growth.\n\t\t\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t\t\t * This is the one card that bypasses MetricFigure, which is exactly where\n\t\t\t\t\t\t\t\t\t * the absent-versus-zero rule stopped being applied.\n\t\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\t\tconst reason = data.audienceGrowth?.status === 'unavailable'\n\t\t\t\t\t\t\t\t\t\t? data.audienceGrowth.detail\n\t\t\t\t\t\t\t\t\t\t: null\n\t\t\t\t\t\t\t\t\treturn reason ? <Text size={0} muted>{reason}</Text> : null\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\t\t\t\t\t{growth > 0 ? '+' : ''}{formatCount(growth)} this period\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t})()}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{data.audience && data.audience.status !== 'unavailable' && (\n\t\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t\tEveryone subscribed today, not just this period. Mailchimp’s own count, so it is\n\t\t\t\t\t\t\t\tnot consent-gated or blockable.\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t</div>\n\n\t\t\t{/*\n\t\t\t * The two ratios a foundry would quote, which the tool held both halves of and never\n\t\t\t * divided. Revenue and Orders sat in adjacent cards; sessions and orders sat on adjacent\n\t\t\t * tabs. Derived here, so neither costs a query.\n\t\t\t */}\n\t\t\t<div style={cardGrid}>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Average order</Label>\n\t\t\t\t\t\t{/* The same treatment as visitors per order: the denominator is the whole\n\t\t\t\t\t\t story here, since at Darden only 2 of 7 orders carry an amount. */}\n\t\t\t\t\t\t<RatioFigure\n\t\t\t\t\t\t\tvalue={metricSortValue(averageOrderValue(data))}\n\t\t\t\t\t\t\tformat={(v) => formatMoney(v, data.currency ?? null)}\n\t\t\t\t\t\t\tparts={[\n\t\t\t\t\t\t\t\t{ label: 'taken', value: metricSortValue(data.revenue), format: (v) => formatMoney(v, data.currency ?? null) },\n\t\t\t\t\t\t\t\t{ label: 'orders with an amount', value: finiteOrNull(data.ordersWithTotal) },\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\tunavailable={<MetricFigure metric={averageOrderValue(data)} label=\"Average order value\" />}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{averageOrderValue(data).status === 'partial' && (averageOrderValue(data) as { note?: string }).note && (\n\t\t\t\t\t\t\t<Text size={0} muted>{(averageOrderValue(data) as { note?: string }).note}</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{averageOrderValue(data).status === 'ok' && (\n\t\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t\t{/* Only when it IS exact. Gated on `!== 'unavailable'` this rendered directly\n\t\t\t\t\t\t\t\t beneath \"Averaged over the 2 of 7 orders that carry an amount\", so the card\n\t\t\t\t\t\t\t\t stated a caveat and then denied it, eight words apart. */}\n\t\t\t\t\t\t\t\tFrom your orders, so exact.\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Visitors per order</Label>\n\t\t\t\t\t\t{/* Turn it over to see what it is made of.\n\t\t\t\t\t\t\n\t\t\t\t\t\t At seven orders a quarter this is the least trustworthy figure on the tab\n\t\t\t\t\t\t and among the most quotable — one more sale moves 337 to 295. The package\n\t\t\t\t\t\t withholds a rate below its floor everywhere else, which is right for a rate\n\t\t\t\t\t\t nobody asked for and wrong for one they did, because it leaves them nothing.\n\t\t\t\t\t\t Showing the counts on the other face lets a reader learn how thin the\n\t\t\t\t\t\t arithmetic is for themselves, which lands harder than a caveat they skim. */}\n\t\t\t\t\t\t<RatioFigure\n\t\t\t\t\t\t\tvalue={metricSortValue(visitorsPerOrder(data))}\n\t\t\t\t\t\t\tformat={(v) => formatCount(v)}\n\t\t\t\t\t\t\tparts={[\n\t\t\t\t\t\t\t\t{ label: 'visitors', value: metricSortValue(data.vercelVisitors) },\n\t\t\t\t\t\t\t\t{ label: 'orders', value: metricSortValue(data.orders) },\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\tunavailable={<MetricFigure metric={visitorsPerOrder(data)} label=\"Visitors per order\" />}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{data.vercelVisitors && data.vercelVisitors.status !== 'unavailable' && (\n\t\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t\tVercel’s visitor count. A ceiling — some are bots or the same person twice.\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t</div>\n\n\t\t\t{(data.crossSource?.length ?? 0) >= 3 && (\n\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t<SectionTitle title=\"Everything, on one time axis\" />\n\t\t\t\t\t{/* Forty words defending a design decision to a reader who never proposed the\n\t\t\t\t\t alternative had escaped from a code comment into the UI. What the reader needs\n\t\t\t\t\t is how to read the chart, not why it was drawn this way. */}\n\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\tEach row has its own scale, so the rows cannot be compared by height — only by\n\t\t\t\t\t\tshape.\n\t\t\t\t\t</Text>\n\t\t\t\t\t<CrossSourceTimeline\n\t\t\t\t\t\tonBrush={onBrush}\n\t\t\t\t\t\tcurrency={data.currency ?? null}\n\t\t\t\t\t\tmarkers={data.timelineEvents ?? []}\n\t\t\t\t\t\tseries={[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// One traffic row, not two. The line is what happened; GA4's view of it\n\t\t\t\t\t\t\t\t// is the shaded area beneath. Two peer lines made the reader reconcile\n\t\t\t\t\t\t\t\t// before getting an answer, and GA4 is not a competing estimate of\n\t\t\t\t\t\t\t\t// pageviews — it is a lossy subset of them.\n\t\t\t\t\t\t\t\tkey: 'traffic',\n\t\t\t\t\t\t\t\tlabel: 'Pageviews',\n\t\t\t\t\t\t\t\tsource: 'Vercel',\n\t\t\t\t\t\t\t\tcomplete: true,\n\t\t\t\t\t\t\t\tunit: 'count',\n\t\t\t\t\t\t\t\tpoints: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.vercelPageviews })),\n\t\t\t\t\t\t\t\t// The same row last period, so the reader can see whether this shape is normal.\n\t\t\t\t\t\t\t\tcomparison: previous?.crossSource?.length\n\t\t\t\t\t\t\t\t\t? previous.crossSource.map((d) => ({ date: d.date, value: d.vercelPageviews }))\n\t\t\t\t\t\t\t\t\t: undefined,\n\t\t\t\t\t\t\t\tshortfall: {\n\t\t\t\t\t\t\t\t\tlabel: 'Seen by GA4',\n\t\t\t\t\t\t\t\t\tsource: 'GA4',\n\t\t\t\t\t\t\t\t\t// PAGEVIEWS, not sessions. The region encodes the difference between\n\t\t\t\t\t\t\t\t\t// these two as an area, so both sides must be the same unit — this\n\t\t\t\t\t\t\t\t\t// previously shaded sessions under a pageview line, overstating the\n\t\t\t\t\t\t\t\t\t// loss by the pageviews-per-session ratio.\n\t\t\t\t\t\t\t\t\tpoints: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.ga4Pageviews })),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t * Coverage as its own row, on a fixed 0–100% axis.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * The shaded band under the traffic line is an ABSOLUTE quantity — Vercel\n\t\t\t\t\t\t\t * minus GA4 — so under a flat 20% coverage it is 0.8 times the traffic curve\n\t\t\t\t\t\t\t * and tracks it exactly. Every busy day therefore shows a wider alarm than\n\t\t\t\t\t\t\t * every quiet day with no change whatever in the instrument, and the one\n\t\t\t\t\t\t\t * distinction this tool exists to draw — a standing shortfall against a dated\n\t\t\t\t\t\t\t * collapse — is the one the encoding could not show. It had to be asserted in\n\t\t\t\t\t\t\t * prose by a detector instead, which made that sentence the reader's only\n\t\t\t\t\t\t\t * witness to its own subject.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * As a proportion it draws itself: a standing shortfall is a flat line, a\n\t\t\t\t\t\t\t * collapse is a step with a date under it, a gradual decay is a ramp that\n\t\t\t\t\t\t\t * plainly is not an event, and a partial recovery is a line that visibly did\n\t\t\t\t\t\t\t * not return. The reader can check the sentence rather than trust it.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t...((data.crossSource ?? []).some((d) => d.vercelPageviews !== null && d.ga4Pageviews !== null)\n\t\t\t\t\t\t\t\t? [{\n\t\t\t\t\t\t\t\t\tkey: 'coverage',\n\t\t\t\t\t\t\t\t\tlabel: 'Share GA4 saw',\n\t\t\t\t\t\t\t\t\tsource: 'GA4' as const,\n\t\t\t\t\t\t\t\t\tcomplete: true,\n\t\t\t\t\t\t\t\t\tunit: 'percent' as const,\n\t\t\t\t\t\t\t\t\t// Fixed at the bottom, open at the top.\n\t\t\t\t\t\t\t\t\t//\n\t\t\t\t\t\t\t\t\t// The lower bound is fixed so a site at a flat 20% cannot scale itself\n\t\t\t\t\t\t\t\t\t// to full height and read as healthy. The UPPER bound is not clamped,\n\t\t\t\t\t\t\t\t\t// because over-counting is a real fault with its own interpretation\n\t\t\t\t\t\t\t\t\t// branch — and the card further down this very file refuses the same\n\t\t\t\t\t\t\t\t\t// clamp in prose, saying `Math.min(1, …)` rendered a tag reporting\n\t\t\t\t\t\t\t\t\t// 250% of reality as a flat 100% under the heading \"How much GA4 is\n\t\t\t\t\t\t\t\t\t// seeing\", i.e. perfect. One quantity, two opposite policies, one\n\t\t\t\t\t\t\t\t\t// file: the row drew a double-firing tag as a healthy flat line while\n\t\t\t\t\t\t\t\t\t// the sentence beside it called the over-count out.\n\t\t\t\t\t\t\t\t\tdomain: [0, Math.max(1, ...(data.crossSource ?? [])\n\t\t\t\t\t\t\t\t\t\t.map((d) => (d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0\n\t\t\t\t\t\t\t\t\t\t\t? d.ga4Pageviews / d.vercelPageviews\n\t\t\t\t\t\t\t\t\t\t\t: 0)))] as [number, number],\n\t\t\t\t\t\t\t\t\tpoints: (data.crossSource ?? []).map((d) => ({\n\t\t\t\t\t\t\t\t\t\tdate: d.date,\n\t\t\t\t\t\t\t\t\t\tvalue: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0\n\t\t\t\t\t\t\t\t\t\t\t? d.ga4Pageviews / d.vercelPageviews\n\t\t\t\t\t\t\t\t\t\t\t: null,\n\t\t\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t\t\t\t// The most valuable ghost in the chart: a collapse reads as a step away\n\t\t\t\t\t\t\t\t\t// from last period's flat line, whether or not the detector fired.\n\t\t\t\t\t\t\t\t\tcomparison: previous?.crossSource?.length\n\t\t\t\t\t\t\t\t\t\t? previous.crossSource.map((d) => ({\n\t\t\t\t\t\t\t\t\t\t\tdate: d.date,\n\t\t\t\t\t\t\t\t\t\t\tvalue: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0\n\t\t\t\t\t\t\t\t\t\t\t\t? d.ga4Pageviews / d.vercelPageviews\n\t\t\t\t\t\t\t\t\t\t\t\t: null,\n\t\t\t\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\t\t\t\t: undefined,\n\t\t\t\t\t\t\t\t}]\n\t\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t\t// ORDERS ALWAYS, revenue only when it covers every order.\n\t\t\t\t\t\t\t//\n\t\t\t\t\t\t\t// This used to draw revenue whenever ANY day had a revenue figure, and\n\t\t\t\t\t\t\t// otherwise orders — never both. Combined with the server filling a day's\n\t\t\t\t\t\t\t// revenue as `revenueByDate[date] ?? 0`, that meant a day carrying a real\n\t\t\t\t\t\t\t// order with no recorded amount arrived as a measured ZERO, and the row drew\n\t\t\t\t\t\t\t// a zero-tick on it: the mark whose own comment says \"measured, and it was\n\t\t\t\t\t\t\t// nothing\".\n\t\t\t\t\t\t\t//\n\t\t\t\t\t\t\t// At Darden 11 of 69 orders carry an amount, so the flagship chart was\n\t\t\t\t\t\t\t// asserting that no sale happened on 58 days that had sales — the package's\n\t\t\t\t\t\t\t// founding distinction inverted inside its most prominent encoding. Orders\n\t\t\t\t\t\t\t// are exact and complete whatever the amounts say, so they are the row that\n\t\t\t\t\t\t\t// is always honest.\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'orders',\n\t\t\t\t\t\t\t\tlabel: 'Orders',\n\t\t\t\t\t\t\t\tsource: 'Sanity' as const,\n\t\t\t\t\t\t\t\tcomplete: true,\n\t\t\t\t\t\t\t\tunit: 'count' as const,\n\t\t\t\t\t\t\t\t// A sale is an event on a date, not a level that varies day to day.\n\t\t\t\t\t\t\t\tmark: 'events' as const,\n\t\t\t\t\t\t\t\tpoints: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.orders })),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t...(revenueCoversEveryOrder(data)\n\t\t\t\t\t\t\t\t? [{\n\t\t\t\t\t\t\t\t\tkey: 'revenue',\n\t\t\t\t\t\t\t\t\tlabel: 'Revenue',\n\t\t\t\t\t\t\t\t\tsource: 'Sanity' as const,\n\t\t\t\t\t\t\t\t\tcomplete: true,\n\t\t\t\t\t\t\t\t\tunit: 'money' as const,\n\t\t\t\t\t\t\t\t\tmark: 'events' as const,\n\t\t\t\t\t\t\t\t\tpoints: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.revenue })),\n\t\t\t\t\t\t\t\t}]\n\t\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{data.vercelDailyUnavailable && (\n\t\t\t\t\t\t// Restored. Vercel's aggregate endpoint caps at 62 day-buckets, so on Quarter and\n\t\t\t\t\t\t// Year the pageview row has no data at all — and the sentence explaining that was\n\t\t\t\t\t\t// deleted along with the \"Day by day\" section when this panel was split, leaving\n\t\t\t\t\t\t// two of the four ranges drawing an empty band under \"Everything, on one time\n\t\t\t\t\t\t// axis\" on the default tab with nothing to say why.\n\t\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\t\tVercel reports a range this long in weekly buckets, so the pageview row is empty\n\t\t\t\t\t\t\there. Choose Week or Month to see it.\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<ChartData<CrossSourceDay>\n\t\t\t\t\t\t// No note. \"A negative miss means GA4 counted more than Vercel that day —\n\t\t\t\t\t\t// usually a tag firing twice\" was the THIRD copy of that sentence on one\n\t\t\t\t\t\t// screen: the timeline's caution card says it against a live finding, and the\n\t\t\t\t\t\t// over-count notice says it again beside the capture estimates. A reader who\n\t\t\t\t\t\t// meets a claim three times stops reading the register it arrives in. It\n\t\t\t\t\t\t// stays where it is attached to something that actually happened.\n\t\t\t\t\t\tlabel=\"Show these figures as a table\"\n\t\t\t\t\t\trows={data.crossSource ?? []}\n\t\t\t\t\t\trowKey={(d) => d.date}\n\t\t\t\t\t\texportName=\"timeline\"\n\t\t\t\t\t\t// Filterable, like every other table in the tool. This one carries the columns\n\t\t\t\t\t\t// the whole package exists for and was the only table with no filter box.\n\t\t\t\t\t\tfilterOn={(d) => d.date}\n\t\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t\t{ key: 'date', label: 'Date', sortValue: (d) => d.date, render: (d) => <Text size={1}>{d.date}</Text> },\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'vercel', label: 'Pageviews', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => d.vercelPageviews,\n\t\t\t\t\t\t\t\trender: (d) => <Text size={1}>{d.vercelPageviews === null ? '—' : formatCount(d.vercelPageviews)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'ga4', label: 'Seen by GA4', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => d.ga4Pageviews,\n\t\t\t\t\t\t\t\trender: (d) => <Text size={1}>{d.ga4Pageviews === null ? '—' : formatCount(d.ga4Pageviews)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t\t * The disagreement, as a number you can sort on.\n\t\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t\t * This table held `Pageviews` and `Seen by GA4` as separate columns and\n\t\t\t\t\t\t\t\t * nothing joining them, so the one quantity this whole tool exists to\n\t\t\t\t\t\t\t\t * surface was the one column missing from the only table that could hold\n\t\t\t\t\t\t\t\t * it. \"Which day did GA4 lose most\" was answerable by eye, or by the\n\t\t\t\t\t\t\t\t * single worst-day sentence under the chart, and by nothing else — not\n\t\t\t\t\t\t\t\t * sortable, not filterable, not in the CSV.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tkey: 'missed', label: 'Missed by GA4', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => gapOf(d),\n\t\t\t\t\t\t\t\trender: (d) => {\n\t\t\t\t\t\t\t\t\tconst gap = gapOf(d)\n\t\t\t\t\t\t\t\t\treturn <Text size={1}>{gap === null ? '—' : formatCount(gap)}</Text>\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t\t * The same disagreement as a proportion, because the two rank days\n\t\t\t\t\t\t\t\t * differently and both questions are real: the absolute column finds the\n\t\t\t\t\t\t\t\t * day that cost the most, this one finds the day the instrument worked\n\t\t\t\t\t\t\t\t * worst. Sorting on it is how a reader dates a collapse.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tkey: 'coverage', label: 'Share GA4 saw', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => coverageOf(d),\n\t\t\t\t\t\t\t\t// The FORMATTED value, matching the cell. Passing the raw ratio through\n\t\t\t\t\t\t\t\t// shipped 0.06666666666666667 into a CSV column whose cell reads \"7%\" —\n\t\t\t\t\t\t\t\t// verbatim the case exportValue exists to prevent.\n\t\t\t\t\t\t\t\texportValue: (d) => {\n\t\t\t\t\t\t\t\t\tconst coverage = coverageOf(d)\n\t\t\t\t\t\t\t\t\treturn coverage === null ? null : formatPercent(coverage, 0)\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\trender: (d) => {\n\t\t\t\t\t\t\t\t\tconst coverage = coverageOf(d)\n\t\t\t\t\t\t\t\t\treturn <Text size={1}>{coverage === null ? '—' : formatPercent(coverage, 0)}</Text>\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'orders', label: 'Orders', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => d.orders,\n\t\t\t\t\t\t\t\trender: (d) => <Text size={1}>{d.orders === null ? '—' : formatCount(d.orders)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'revenue', label: 'Revenue', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => d.revenue,\n\t\t\t\t\t\t\t\texportValue: (d) => (d.revenue === null ? null : formatMoney(d.revenue, data.currency ?? null)),\n\t\t\t\t\t\t\t\trender: (d) => <Text size={1}>{d.revenue === null ? '—' : formatMoney(d.revenue, data.currency ?? null)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t)}\n\n\n\t\t\t{/* STAYS HERE, open, and a real Section.\n\t\t\t\n\t\t\t A review argued this belongs on Acquisition — an email send is a channel with a date\n\t\t\t and a click count, sitting on the default tab only because Mailchimp arrives in this\n\t\t\t report's envelope. Two things say otherwise.\n\t\t\t\n\t\t\t The valuable half cannot travel. `attributeToSends` computes \"Orders after\" and\n\t\t\t \"Per 1,000 sent\" from `crossSource`, the daily order series assembled on THIS report,\n\t\t\t and `timelineEvents` derives its markers from these same campaigns — so moving the\n\t\t\t table would make Acquisition fetch Mailchimp and the order series independently while\n\t\t\t this report kept fetching Mailchimp anyway. Two duplicated upstream calls to relocate\n\t\t\t a table.\n\t\t\t\n\t\t\t And it must not fold. \"Did that send sell anything\" is asked within hours of every\n\t\t\t send, two to four times a month; this is the one email question Google Analytics\n\t\t\t cannot answer, because both the send times and the order times here are exact. The\n\t\t\t band below folds material that is CONSULTED. This is read.\n\t\t\t\n\t\t\t What it gains is the subtitle, which carries the one claim these columns do not\n\t\t\t make — the half a reader who takes \"after\" for \"because of\" will otherwise miss. */}\n\t\t\t{(data.campaigns?.length ?? 0) > 0 && (\n\t\t\t\t<Section\n\t\t\t\t\ttitle=\"Email campaigns\"\n\t\t\t\t\tsubtitle=\"What your order book recorded after each send — what happened next, not what the send caused.\"\n\t\t\t\t>\n\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\tClicks are distinct subscribers. Opens are not shown: Apple Mail fetches images on\n\t\t\t\t\t\tthe recipient’s behalf, so an open is often the mail client rather than a person.\n\t\t\t\t\t</Text>\n\t\t\t\t\t{/* Said once, above the table, rather than in a tooltip on every cell. The claim these\n\t\t\t\t\t columns do NOT make is the important half, and a reader who takes \"after\" for\n\t\t\t\t\t \"because of\" will over-credit the newsletter. */}\n\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\tThe last two columns count what your order book records in the {SEND_WINDOW_DAYS} days\n\t\t\t\t\t\tafter each send, ending early if another send lands first. They are what happened\n\t\t\t\t\t\tnext, not what the send caused — but unlike anything GA4 can tell you about\n\t\t\t\t\t\temail, both the send times and the order times are exact.\n\t\t\t\t\t</Text>\n\t\t\t\t\t<SortableTable<EmailCampaign>\n\t\t\t\t\t\tcaption=\"Email campaigns by clicks\"\n\t\t\t\t\t\tinitialSort=\"clicks\"\n\t\t\t\t\t\trows={data.campaigns ?? []}\n\t\t\t\t\t\trowKey={(c) => `${c.sentAt}-${c.title}`}\n\t\t\t\t\t\tfilterOn={(c) => `${c.title} ${c.subject}`}\n\t\t\t\t\t\tfilterPlaceholder=\"Filter campaigns\"\n\t\t\t\t\t\texportName=\"email-campaigns\"\n\t\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'title',\n\t\t\t\t\t\t\t\tlabel: 'Campaign',\n\t\t\t\t\t\t\t\tsortValue: (c) => c.title,\n\t\t\t\t\t\t\t\trender: (c) => (\n\t\t\t\t\t\t\t\t\t<Stack space={1}>\n\t\t\t\t\t\t\t\t\t\t<Text size={1}>{c.title}</Text>\n\t\t\t\t\t\t\t\t\t\t<Text size={0} muted>{c.sentAt.slice(0, 10)}</Text>\n\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{ key: 'sent', label: 'Sent', numeric: true, sortValue: (c) => c.sent, render: (c) => <Text size={1}>{formatCount(c.sent)}</Text> },\n\t\t\t\t\t\t\t// No Opens column. The caption above the table told the reader, in the tool's\n\t\t\t\t\t\t\t// own words, to \"sort on clicks, not opens\" because Apple Mail Privacy\n\t\t\t\t\t\t\t// Protection fetches images on the recipient's behalf. A column whose own\n\t\t\t\t\t\t\t// caption instructs you not to read it should not be a column — and the\n\t\t\t\t\t\t\t// warning gets shorter as a result.\n\t\t\t\t\t\t\t{ key: 'clicks', label: 'Clicks', numeric: true, sortValue: (c) => c.clicks, render: (c) => <Text size={1}>{formatCount(c.clicks)}</Text> },\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'unsub',\n\t\t\t\t\t\t\t\tlabel: 'Unsubscribed',\n\t\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t\tsortValue: (c) => c.unsubscribed,\n\t\t\t\t\t\t\t\trender: (c) => <Text size={1} muted>{formatCount(c.unsubscribed)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'ordersAfter',\n\t\t\t\t\t\t\t\tlabel: 'Orders after',\n\t\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t\tsortValue: (c) => c.ordersAfter ?? null,\n\t\t\t\t\t\t\t\texportValue: (c) => c.ordersAfter ?? null,\n\t\t\t\t\t\t\t\trender: (c) => (\n\t\t\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\t\t\t{c.ordersAfter === null || c.ordersAfter === undefined ? '—' : formatCount(c.ordersAfter)}\n\t\t\t\t\t\t\t\t\t\t{/* A send on the last day of the range has not had its days yet. Without\n\t\t\t\t\t\t\t\t\t\t this it reads as a campaign that sold nothing, which is a conclusion\n\t\t\t\t\t\t\t\t\t\t about the campaign drawn from the shape of the window. */}\n\t\t\t\t\t\t\t\t\t\t{c.windowComplete === false && c.ordersAfter !== null && c.ordersAfter !== undefined && (\n\t\t\t\t\t\t\t\t\t\t\t<Text as=\"span\" size={0} muted> so far</Text>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'revenuePerThousand',\n\t\t\t\t\t\t\t\tlabel: 'Per 1,000 sent',\n\t\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t\t// Per thousand, not per send: a foundry's sends run to thousands of addresses\n\t\t\t\t\t\t\t\t// against a handful of orders, so per-send lands at fractions of a cent and\n\t\t\t\t\t\t\t\t// every campaign renders as the same rounded zero.\n\t\t\t\t\t\t\t\tsortValue: (c) => revenuePerThousandSent(c),\n\t\t\t\t\t\t\t\texportValue: (c) => revenuePerThousandSent(c),\n\t\t\t\t\t\t\t\trender: (c) => {\n\t\t\t\t\t\t\t\t\tconst value = revenuePerThousandSent(c)\n\t\t\t\t\t\t\t\t\tif (value === null) return <Text size={1}>—</Text>\n\t\t\t\t\t\t\t\t\t// The rate AND its numerator, because the denominator is already a column.\n\t\t\t\t\t\t\t\t\t//\n\t\t\t\t\t\t\t\t\t// A card gets a toggle to turn a ratio over and show what it is made of;\n\t\t\t\t\t\t\t\t\t// a table cannot, without twenty buttons. But a table does not need one:\n\t\t\t\t\t\t\t\t\t// `Sent` is a column already, so printing the money underneath completes\n\t\t\t\t\t\t\t\t\t// the arithmetic in place. The reader can see that US$350 per thousand is\n\t\t\t\t\t\t\t\t\t// US$700 over 2,000 addresses, and judge it.\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<Stack space={1}>\n\t\t\t\t\t\t\t\t\t\t\t<Text size={1}>{formatMoney(value, data.currency ?? null)}</Text>\n\t\t\t\t\t\t\t\t\t\t\t{c.revenueAfter !== null && c.revenueAfter !== undefined && (\n\t\t\t\t\t\t\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t\t\t\t\t\t\tfrom {formatMoney(c.revenueAfter, data.currency ?? null)}\n\t\t\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\t\t\t\t</Section>\n\t\t\t)}\n\n\t\t</div>\n\t)\n}\n\n/**\n * One line saying whether this week was normal.\n *\n * Every input was already computed and the reader was left to derive the conclusion themselves,\n * from levels, across four tabs, by comparing arrows. For someone with five minutes on a Monday\n * that derivation IS the work, and the tool was making them do all of it.\n */\nfunction Verdict({ data, previous }: { data: MeasurementHealthData; previous?: MeasurementHealthData }): React.ReactElement | null {\n\tconst parts: string[] = []\n\n\tconst say = (label: string, now: MetricValue | undefined, before: MetricValue | undefined, unit: 'money' | 'count') => {\n\t\tconst a = metricSortValue(now)\n\t\tconst b = metricSortValue(before)\n\t\tif (a === null) return\n\t\tif (b === null || b === 0) {\n\t\t\tparts.push(`${label} ${unit === 'money' ? formatMoney(a, data.currency ?? null) : formatCount(a)}`)\n\t\t\treturn\n\t\t}\n\t\tconst change = (a - b) / Math.abs(b)\n\t\t/*\n\t\t * \"Flat\" carries its number when there is one.\n\t\t *\n\t\t * Two definitions of flat sat ten pixels apart on the same panel: this line called a move\n\t\t * under 5% flat, while the card beneath it drew an arrow above 0.5% — so the headline could\n\t\t * read \"Traffic flat\" in the largest type directly above a Traffic card reading \"↑ +4% from\n\t\t * 2,266\". The suppression is still right at these volumes; contradicting the card was not.\n\t\t * Saying \"little changed (+4%)\" keeps the judgement and agrees with what is on screen.\n\t\t */\n\t\tif (Math.abs(change) < 0.05) {\n\t\t\tparts.push(Math.abs(change) < 0.005\n\t\t\t\t? `${label} flat`\n\t\t\t\t: `${label} little changed (${change > 0 ? '+' : '−'}${formatPercent(Math.abs(change), 0)})`)\n\t\t}\n\t\t/*\n\t\t * A money move carries its amounts, and a percentage alone is suppressed on thin volumes.\n\t\t *\n\t\t * \"Revenue down 62%\" was printed in the largest type on the page. At two orders a month that\n\t\t * is one order not landing, and the card beneath — which does print its baseline — is smaller\n\t\t * and read second. Every neighbouring component already knows this: Delta's own comment says\n\t\t * an 18% move might be one order, and the typeface blurb says a family with one or two sales\n\t\t * swings a long way. The headline was the one place that skipped it.\n\t\t */\n\t\telse if (unit === 'money') {\n\t\t\tparts.push(`${label} ${change > 0 ? 'up' : 'down'} to ${formatMoney(a, data.currency ?? null)} from ${formatMoney(b, data.currency ?? null)}`)\n\t\t}\n\t\t// Counts below the floor state themselves rather than a ratio, exactly as Delta now does on\n\t\t// the card beside this sentence. Otherwise the verdict read \"Orders up 75%\" in the largest\n\t\t// type on the tab while the Orders card directly beneath it said \"↑ from 4\" with no\n\t\t// percentage at all — the headline and its own evidence disagreeing on one screen, which is\n\t\t// the specific failure this whole review kept finding.\n\t\telse if (Math.abs(b) < MIN_DELTA_BASE) {\n\t\t\tparts.push(`${label} ${formatCount(a)}, was ${formatCount(b)}`)\n\t\t}\n\t\telse parts.push(`${label} ${change > 0 ? 'up' : 'down'} ${formatPercent(Math.abs(change), 0)}`)\n\t}\n\n\tsay('Revenue', data.revenue, previous?.revenue, 'money')\n\t// Orders, between the two. At single-digit volumes this is the least noisy business signal the\n\t// tool has, and the verdict omitted it while finding room for a clause about GA4's health.\n\tsay('Orders', data.orders, previous?.orders, 'count')\n\tsay('Traffic', data.vercelPageviews, previous?.vercelPageviews, 'count')\n\n\t// Not a verdict clause. That you sent an email is not a finding — you sent it — and it was\n\t// occupying a slot in the one line the reader is meant to act on. The campaigns table below\n\t// says how many, and the timeline marks when.\n\n\t// What \"broken\" means, in priority order.\n\t//\n\t// This used to consult `capture.discrepancy` alone — which is null whenever fewer than two\n\t// capture estimates exist, and at seven orders a quarter the orders and email estimates are\n\t// almost always below their minimum denominator on a Week range. So the only surviving estimate\n\t// was pageviews, the discrepancy was null, and the line appended \"nothing broken\".\n\t//\n\t// The 24 August collapse — an 86% shortfall running ten days, the founding failure this whole\n\t// package cites — would therefore have read, on a Monday: \"Revenue flat · traffic flat ·\n\t// nothing broken\". The figure that names it was computed and rendered two tabs away.\n\t// Graded, not a single cliff at 0.6. The threshold was one hard step, so a site where GA4 saw\n\t// 45% of its traffic — less than half — got a clean bill of health in the largest type on the\n\t// default tab, and a twelve-day collapse inside a ninety-day quarter averaged down below the\n\t// line and vanished entirely.\n\tconst shortfall = data.shortfallRatio\n\tconst seeing = shortfall !== null && shortfall !== undefined\n\t\t? `GA4 is seeing ${formatPercent(1 - shortfall, 0)} of your traffic`\n\t\t: null\n\tconst broken = shortfall === null || shortfall === undefined\n\t\t? (data.capture?.discrepancy ? 'measurement disagrees between sources' : null)\n\t\t: shortfall > 0.6\n\t\t\t? `${seeing} — treat its figures as broken`\n\t\t\t: shortfall > 0.35\n\t\t\t\t? `${seeing} — its figures are undercounts, not what happened`\n\t\t\t\t: data.capture?.discrepancy\n\t\t\t\t\t? 'measurement disagrees between sources'\n\t\t\t\t\t: null\n\t// NOT pushed into `parts`. It was joined to the business facts with a middot and set in the same\n\t// size, so three facts about the foundry and one about Google Analytics were grammatical peers —\n\t// and on a site where GA4 sees a fifth of its traffic, the amber alarm on the default tab led\n\t// with the plumbing while the revenue sat inside it as a clause. Every word is kept; only the\n\t// rank changes.\n\n\t// Not null.\n\t//\n\t// `parts` fills only from figures that arrived, so this branch is reached exactly where the\n\t// reader most needs a sentence: a new site, a window with no orders, or a route older than the\n\t// fields the verdict reads. The tab then rendered six em-dashes and a footer, with nothing\n\t// anywhere saying there was nothing to report — which reads as the tool being broken rather\n\t// than the window being empty.\n\t// `broken` no longer fills `parts`, so this guard has to count it too — otherwise a window with a\n\t// coverage reading and no business figures, which is exactly the state where that reading is the\n\t// only thing worth saying, would render \"No figures arrived\".\n\tif (parts.length === 0 && !broken) {\n\t\treturn (\n\t\t\t<Card padding={3} radius={2} tone=\"transparent\" style={{ borderLeftWidth: 3, borderLeftStyle: 'solid' }}>\n\t\t\t\t<Text size={4}>No figures arrived for this window.</Text>\n\t\t\t</Card>\n\t\t)\n\t}\n\n\t// \"Nothing broken\" needs a positive test, not the absence of a warning. It used to be appended\n\t// whenever `capture.discrepancy` was falsy — which includes capture being absent entirely, so an\n\t// older route or an unconfigured site printed a confident all-clear on no evidence at all.\n\t// No closing claim at all.\n\t//\n\t// This used to append \"· nothing broken\" — a statement about the site, the checkout, the orders\n\t// and four other tabs, resting on a single ratio about Google Analytics. It read as \"the\n\t// business is fine\". Between a fifth and a third of loss the coverage is worth STATING, because\n\t// it is a real qualifier on the traffic figure beside it, but it is not a verdict; below that,\n\t// silence. Silence here means nothing was flagged, which is all this line ever knew.\n\tconst noticeable = shortfall !== null && shortfall !== undefined && shortfall > 0.2\n\tconst closing = broken || !noticeable ? '' : ` · ${seeing}`\n\n\treturn (\n\t\t<Card\n\t\t\tpadding={3}\n\t\t\tradius={2}\n\t\t\ttone={broken ? 'caution' : 'transparent'}\n\t\t\tborder\n\t\t\t// An explicit border alongside the tone: the compat shim's fallback drops `tone`, and\n\t\t\t// this card is the tool's only alarm. Losing its colour must not lose the signal.\n\t\t\tstyle={broken ? { borderLeftWidth: 3, borderLeftStyle: 'solid' } : undefined}\n\t\t>\n\t\t\t<Stack space={2}>\n\t\t\t\t{/* size={4} — 21px, the same as the figures beneath it.\n\t\t\t\t\n\t\t\t\t An earlier pass moved this 15 -> 18px with a comment claiming it had been fixed,\n\t\t\t\t but `MetricFigure` defaults to size={4} = 21px, so the panel's one sentence was\n\t\t\t\t still smaller than the cards it summarises and still read as their caption. Parity\n\t\t\t\t is the floor here: this is the only line the tool exists to say. */}\n\t\t\t\t{parts.length > 0 && (\n\t\t\t\t\t<Text size={4} weight={broken ? 'semibold' : 'medium'}>{parts.join(' \\u00b7 ')}{closing}</Text>\n\t\t\t\t)}\n\t\t\t\t{/* The instrument, beneath and quieter. It is a real qualifier on the traffic figure\n\t\t\t\t and it stays on the alarm card — it is simply no longer the first thing read. */}\n\t\t\t\t{broken && <Text size={1} muted>{broken}</Text>}\n\t\t\t</Stack>\n\t\t</Card>\n\t)\n}\n\n/**\n * Data health — the instrument, deliberately last.\n *\n * Everything here answers one question: can I trust the numbers on the other tabs. It was two\n * separate tabs answering that question, which is one more than it deserves out of five.\n */\nexport function DataHealthPanel({ data, diagnostics }: { data: MeasurementHealthData; diagnostics?: DiagnosticReport }): React.ReactElement {\n\t// Whether a part-inside-whole bar is honest here — see the comment at the render site.\n\tconst contained = isContainment(data.vercelPageviews, data.ga4Pageviews)\n\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t{/* The reading is always shown. It used to be nested inside the shortfall block, so a\n\t\t\t range where only one source answered rendered bare numbers and no explanation of why\n\t\t\t there was nothing to compare — the state where the explanation matters most. */}\n\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t<Stack space={2}>\n\t\t\t\t\t{data.shortfallRatio !== null && (\n\t\t\t\t\t\t<Text size={1} weight=\"semibold\">\n\t\t\t\t\t\t\t{/* Named by direction rather than always as a GA4 shortfall. The ratio goes\n\t\t\t\t\t\t\t negative whenever GA4 sees more than Vercel — routine where Vercel's\n\t\t\t\t\t\t\t collection started later than the range, as on MCKL — and the label used\n\t\t\t\t\t\t\t to read \"GA4 shortfall\" over an absolute value, stating the opposite of\n\t\t\t\t\t\t\t the truth while the sentence below it said \"more\". */}\n\t\t\t\t\t\t\t{data.shortfallRatio >= 0\n\t\t\t\t\t\t\t\t? `GA4 saw ${formatPercent(data.shortfallRatio, 1)} fewer pageviews than Vercel`\n\t\t\t\t\t\t\t\t: `GA4 saw ${formatPercent(-data.shortfallRatio, 1)} more pageviews than Vercel`}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)}\n\t\t\t\t\t<Text size={1} muted>{data.interpretation}</Text>\n\t\t\t\t</Stack>\n\t\t\t</Card>\n\n\t\t\t{/* The evidence, AFTER the reading it supports.\n\t\t\t\n\t\t\t This tab exists to answer one question — can I trust the other tabs — and it opened\n\t\t\t with a section title, an explanatory line and a bar, then stated the answer fourth.\n\t\t\t A reader with five minutes met the working before the conclusion. */}\n\t\t\t<div style={stackBlock}>\n\t\t\t\t<SectionTitle title=\"Pageviews, source against source\" />\n\t\t\t\t<Text size={1} muted>\n\t\t\t\t\tBoth counting pageviews. Google Analytics is blockable, so seeing fewer is normal.\n\t\t\t\t</Text>\n\t\t\t\t{/* One bar, because this is a part inside a whole and not two rivals.\n\t\t\t\t\n\t\t\t\t As two peer bars scaled to the larger of the pair, Vercel's was full width on\n\t\t\t\t every site and every range — no information at all — and a caption had to say in\n\t\t\t\t words that the other was \"a subset of the bar above, not a rival measurement\".\n\t\t\t\t The encoding asserted the opposite of the truth and the prose took it back.\n\t\t\t\t\n\t\t\t\t ContainmentBar returns null where the part exceeds the whole, which is real:\n\t\t\t\t GA4 counts more than Vercel wherever Vercel's collection started after the range\n\t\t\t\t began. There the two-bar form is still the honest picture, so it is kept as the\n\t\t\t\t fallback rather than forcing a containment that does not hold. */}\n\t\t\t\t{contained ? (\n\t\t\t\t\t<ContainmentBar\n\t\t\t\t\t\twholeLabel=\"Pageviews Vercel counted\"\n\t\t\t\t\t\twhole={data.vercelPageviews}\n\t\t\t\t\t\tpartLabel=\"Seen by Google Analytics\"\n\t\t\t\t\t\tpart={data.ga4Pageviews}\n\t\t\t\t\t\tmissingLabel=\"it missed\"\n\t\t\t\t\t/>\n\t\t\t\t) : isContainment(data.ga4Pageviews, data.vercelPageviews) ? (\n\t\t\t\t\t/* Still a containment — the operands are simply the other way round.\n\t\t\t\t\t\n\t\t\t\t\t When GA4 counts MORE than Vercel, it is because Vercel started collecting after\n\t\t\t\t\t the range began, so Vercel's count is the subset. Falling back to two peer bars\n\t\t\t\t\t scaled to the larger put GA4 at full width saying nothing, which is verbatim the\n\t\t\t\t\t defect ContainmentBar was built to kill. */\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<ContainmentBar\n\t\t\t\t\t\t\twholeLabel=\"Pageviews Google Analytics counted\"\n\t\t\t\t\t\t\twhole={data.ga4Pageviews}\n\t\t\t\t\t\t\tpartLabel=\"Also counted by Vercel\"\n\t\t\t\t\t\t\tpart={data.vercelPageviews}\n\t\t\t\t\t\t\tmissingLabel=\"Vercel did not see\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\tVercel counted fewer because its collection started after this range began, not\n\t\t\t\t\t\t\tbecause the traffic was not there.\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t) : (\n\t\t\t\t\t/* Neither containment holds, because only one source answered.\n\t\t\t\t\t\n\t\t\t\t\t Deleting ComparisonBar took this case with it: with GA4 down, both containments\n\t\t\t\t\t return null and the section rendered NOTHING — not even the figure Vercel did\n\t\t\t\t\t report. That is the state where the reader most needs to see what survived, and\n\t\t\t\t\t it is the one the card below spends a comment explaining. */\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<Label size={1} muted>Pageviews Vercel counted</Label>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.vercelPageviews, OLDER_ROUTE)} label=\"Vercel pageviews\" size={3} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<Label size={1} muted>Seen by Google Analytics</Label>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.ga4Pageviews, OLDER_ROUTE)} label=\"GA4 pageviews\" size={3} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\tOnly one source answered for this range, so there is nothing to compare.\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\n\t\t\t{/* Moved up from the bottom of the tab.\n\t\t\t\n\t\t\t Its own checks are the only thing on this tab a reader can ACT on — each carries a\n\t\t\t remedy — and it sat fifth, after two sections of measurement and two of reference.\n\t\t\t The tab asks \"can I trust this\"; the answer to \"then what do I fix\" should not be\n\t\t\t below the material it explains. */}\n\t\t\t{diagnostics && (\n\t\t\t\t/* Open, and primary. This is the longest block on the tab and the obvious candidate\n\t\t\t\t for folding away — but the tab it sits on exists to answer \"what should I fix\n\t\t\t\t before trusting any of this\", and these checks ARE that answer. Folding it would\n\t\t\t\t have left Data health opening on a summary of the problem with the solution behind\n\t\t\t\t a click. It stays collapsible so a reader who has read it can get it out of the\n\t\t\t\t way. */\n\t\t\t\t<Section\n\t\t\t\t\ttitle=\"Configuration\"\n\t\t\t\t\tsubtitle=\"What this site has wired up, and what it is missing.\"\n\t\t\t\t\tcollapsible\n\t\t\t\t>\n\t\t\t\t\t<DiagnosticsPanel data={diagnostics} />\n\t\t\t\t</Section>\n\t\t\t)}\n\n\t\t\t{((data.capture?.estimates?.length ?? 0) > 0 || data.estimatedSessions?.status === 'estimated') && (\n\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t<SectionTitle title=\"How much GA4 is seeing\" />\n\t\t\t\t\t{/* One axis, not three cards.\n\t\t\t\t\t\n\t\t\t\t\t capture.ts is explicit that the three estimates are deliberately NOT averaged,\n\t\t\t\t\t because the disagreement between them is the useful output. A card grid makes\n\t\t\t\t\t that disagreement a subtraction the reader does by eye — and auto-fit reorders\n\t\t\t\t\t the cards by pane width, so even their sequence moves. */}\n\t\t\t\t\t<EstimateDotPlot\n\t\t\t\t\t\testimates={data.capture?.estimates ?? []}\n\t\t\t\t\t\tlabelFor={(basis) => basis === 'orders' ? 'Checked against your orders'\n\t\t\t\t\t\t\t: basis === 'email' ? 'Checked against email clicks'\n\t\t\t\t\t\t\t\t: 'Checked against Vercel'}\n\t\t\t\t\t\tintervalFor={samplingInterval}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{/* The point of holding three estimates. Rendered in caution tone because a\n\t\t\t\t\t disagreement is a finding, not context. */}\n\t\t\t\t\t{data.capture?.discrepancy && (\n\t\t\t\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t\t\t<Text size={1} weight=\"medium\">The sources disagree, and that is informative</Text>\n\t\t\t\t\t\t\t\t<Text size={1}>{data.capture.discrepancy}</Text>\n\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{data.estimatedSessions && data.estimatedSessions.status === 'estimated' && (\n\t\t\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t\t<Label size={1} muted>Sessions, corrected for what GA4 misses</Label>\n\t\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.estimatedSessions, OLDER_ROUTE)} label=\"Estimated sessions\" />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\n\n\t\t\t{/* A real Section now, not a bare title — so it can carry its own tone, fold, and keep its\n\t\t\t instruction visible while folded. The instruction moved into the subtitle for exactly\n\t\t\t that reason: it is the one sentence a reader needs before they go looking, and Section\n\t\t\t renders a subtitle outside the fold.\n\t\t\t\n\t\t\t Consulted once ever, when configuring which statuses count as a sale. */}\n\t\t\t{Object.keys(data.orderStatuses ?? {}).length > 0 && (\n\t\t\t\t<Section\n\t\t\t\t\ttitle=\"Order statuses in this range\"\n\t\t\t\t\ttone=\"secondary\"\n\t\t\t\t\tsubtitle=\"What the orders actually say, before any filtering. Use these values to set which statuses count as a sale.\"\n\t\t\t\t\tcollapsible\n\t\t\t\t\tdefaultOpen={false}\n\t\t\t\t>\n\t\t\t\t\t{/* Without this nobody can configure which statuses count as a sale — and getting\n\t\t\t\t\t that wrong zeroes every order-derived figure in the tool with nothing on screen\n\t\t\t\t\t to explain it. */}\n\t\t\t\t\t{/* A proportion, not a row of peer cards.\n\t\t\t\t\t\n\t\t\t\t\t The reader's question is what SHARE of the order book is the status configured\n\t\t\t\t\t as a sale — and a card each, sorted by count, makes that a sum done by eye.\n\t\t\t\t\t ProportionChart already prints share-and-absolute against a stated total, so\n\t\t\t\t\t the configuration mistake that zeroes every order figure becomes visible\n\t\t\t\t\t rather than arithmetic. */}\n\t\t\t\t\t<ProportionChart\n\t\t\t\t\t\tbars={Object.entries(data.orderStatuses ?? {}).map(([status, count]) => ({\n\t\t\t\t\t\t\tkey: status,\n\t\t\t\t\t\t\tlabel: status,\n\t\t\t\t\t\t\tvalue: count,\n\t\t\t\t\t\t}))}\n\t\t\t\t\t\tformat={(n) => formatCount(n)}\n\t\t\t\t\t\ttotalLabel=\"Orders in this range\"\n\t\t\t\t\t/>\n\t\t\t\t</Section>\n\t\t\t)}\n\n\t\t\t<Section\n\t\t\t\ttitle=\"Context\"\n\t\t\t\ttone=\"secondary\"\n\t\t\t\tsubtitle=\"Different units to the figures above, and to each other.\"\n\t\t\t\tcollapsible\n\t\t\t\tdefaultOpen={false}\n\t\t\t>\n\t\t\t\t<div style={cardGrid}>\n\t\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t<Label size={1} muted>Vercel visitors</Label>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.vercelVisitors, OLDER_ROUTE)} label=\"Vercel visitors\" />\n\t\t\t\t\t\t\t{/* Not \"server-side\". Vercel Web Analytics is the @vercel/analytics client script\n\t\t\t\t\t\t\t on a first-party path — blocked by fewer lists than GA4, and blocked. */}\n\t\t\t\t\t\t\t<Text size={0} muted>Vercel’s own counter. Blocked far less often than Google Analytics, but not never — so read it as a floor on your real traffic.</Text>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t<Label size={1} muted>GA4 sessions</Label>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.ga4Sessions, OLDER_ROUTE)} label=\"GA4 sessions\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t<Label size={1} muted>Consent granted</Label>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.consentRate, OLDER_ROUTE)} label=\"Consent granted, share of visitors GA4 saw\" unit=\"percent\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Card>\n\t\t\t\t</div>\n\t\t\t</Section>\n\n\t\t</div>\n\t)\n}\n\n/** Acquisition — where visitors came from, with design-industry referrers called out. */\nexport function AcquisitionPanel({ data, previous }: { data: AcquisitionData; previous?: AcquisitionData }): React.ReactElement {\n\tconst sessions = finiteOrNull(data.totalSessions)\n\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t<div style={cardGrid}>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Sessions</Label>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<Text size={4}>{sessions === null ? '\\u2014' : formatCount(sessions)}</Text>\n\t\t\t\t\t\t\t<Delta current={sessions} previous={finiteOrNull(previous?.totalSessions)} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t</div>\n\n\t\t\t<div style={stackBlock}>\n\t\t\t\t<SectionTitle title=\"Traffic sources\" />\n\t\t\t\t<Text size={1} muted>Sort or filter to find a source. Excluding a row hides it from this table.</Text>\n\t\t\t\t<SortableTable<SourceRow>\n\t\t\t\t\tcaption=\"Traffic sources by sessions\"\n\t\t\t\t\tinitialSort=\"sessions\"\n\t\t\t\t\trows={data.rows ?? []}\n\t\t\t\t\t// Every dimension the report requests, not just two of them. With medium and\n\t\t\t\t\t// campaign added, several rows share a source and channel — which is the point\n\t\t\t\t\t// of the column — and a two-part key gave duplicate React keys and made one\n\t\t\t\t\t// exclude click remove every row that shared it.\n\t\t\t\t\trowKey={acquisitionRowKey}\n\t\t\t\t\tfilterOn={(row) => `${row.source} ${row.channel} ${row.medium ?? ''} ${row.campaign ?? ''}`}\n\t\t\t\t\tfilterPlaceholder=\"Filter sources\"\n\t\t\t\t\texportName=\"traffic-sources\"\n\t\t\t\t\ttruncatedNote={shortListNote(\n\t\t\t\t\t\tBoolean(data.rowsTruncated),\n\t\t\t\t\t\tBoolean(data.rowsWithheld),\n\t\t\t\t\t\t'For a foundry the tail of small design blogs is often the referral story.',\n\t\t\t\t\t)}\n\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'source',\n\t\t\t\t\t\t\tlabel: 'Source',\n\t\t\t\t\t\t\tsortValue: (row) => row.source,\n\t\t\t\t\t\t\trender: (row) => (\n\t\t\t\t\t\t\t\t<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>\n\t\t\t\t\t\t\t\t\t{/* A referrer host is a real destination, so it links. GA4's own\n\t\t\t\t\t\t\t\t\t buckets — (direct), (not set) — are not hosts and must not\n\t\t\t\t\t\t\t\t\t pretend to be, so only a dotted hostname gets an anchor. */}\n\t\t\t\t\t\t\t\t\t{/* Inside Text, not beside it. A bare anchor inherited the cell's default\n\t\t\t\t\t\t\t\t\t size while the plain branch was sized by Text, so whether a source\n\t\t\t\t\t\t\t\t\t linked changed how big it was — the rows read as two ranks of\n\t\t\t\t\t\t\t\t\t importance when the only difference is that one is a real host.\n\t\t\t\t\t\t\t\t\t The underline is what says it is a link. */}\n\t\t\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\t\t\t{isLinkableHost(row.source)\n\t\t\t\t\t\t\t\t\t\t\t? (\n\t\t\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\t\t\thref={`https://${row.source}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={sourceLink}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{row.source}\n\t\t\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t: row.source}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t{row.designIndustry && <Badge tone=\"primary\" fontSize={0}>Design</Badge>}\n\t\t\t\t\t\t\t\t\t{row.unattributed && <Badge tone=\"caution\" fontSize={0}>No source</Badge>}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'channel',\n\t\t\t\t\t\t\tlabel: 'Channel',\n\t\t\t\t\t\t\tsortValue: (row) => row.channel,\n\t\t\t\t\t\t\trender: (row) => <Text size={1}>{row.channel}</Text>,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'campaign',\n\t\t\t\t\t\t\tlabel: 'Campaign',\n\t\t\t\t\t\t\tsortValue: (row) => row.campaign,\n\t\t\t\t\t\t\texportValue: (row) => row.campaign ?? row.medium ?? '',\n\t\t\t\t\t\t\t// Every campaign, ad group and keyword used to collapse into one row, so\n\t\t\t\t\t\t\t// there was no unit of spend here that a buyer could pause.\n\t\t\t\t\t\t\trender: (row) => row.campaign\n\t\t\t\t\t\t\t\t? <Text size={1}>{row.campaign}</Text>\n\t\t\t\t\t\t\t\t: <Text size={1} muted>{row.medium ?? '—'}</Text>,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'sessions',\n\t\t\t\t\t\t\tlabel: 'Sessions',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => row.sessions,\n\t\t\t\t\t\t\trender: (row) => <Text size={1}>{formatCount(row.sessions)}</Text>,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'engagement',\n\t\t\t\t\t\t\tlabel: 'Engaged',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => finiteOrNull(row.engagementRate),\n\t\t\t\t\t\t\texportValue: (row) => { const r = finiteOrNull(row.engagementRate); return r === null ? null : formatPercent(r, 0) },\n\t\t\t\t\t\t\t// The quality signal. Ranked by volume alone, 27 Display sessions looked\n\t\t\t\t\t\t\t// equal to 27 from Search, which flatters the worst line of spend.\n\t\t\t\t\t\t\trender: (row) => finiteOrNull(row.engagementRate) === null\n\t\t\t\t\t\t\t\t? <Text size={1} muted>—</Text>\n\t\t\t\t\t\t\t\t: <Text size={1}>{formatPercent(row.engagementRate as number, 0)}</Text>,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t * What the channel BROUGHT, which is the question a foundry opens this tool\n\t\t\t\t\t\t\t * with and the one it could not answer. Sessions and engagement rank\n\t\t\t\t\t\t\t * channels by attention; nothing ranked them by money, so \"is the design\n\t\t\t\t\t\t\t * press worth chasing\" and \"did the newsletter sell anything\" were\n\t\t\t\t\t\t\t * unaskable while revenue sat one tab away, split by typeface and by\n\t\t\t\t\t\t\t * licence — the two cuts already available from the order list.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * Sorted on the SHARE, so the ordering holds even when Sanity supplies no\n\t\t\t\t\t\t\t * total and the money column is empty.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tkey: 'brought',\n\t\t\t\t\t\t\t// The hedge belongs on the SPLIT, not on the money: the total is exact and the\n\t\t\t\t\t\t\t// division of it is the estimate. \"(est.)\" on a revenue header implied the reverse.\n\t\t\t\t\t\t\tlabel: 'Revenue, split',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => finiteOrNull(row.revenueShare),\n\t\t\t\t\t\t\texportValue: (row) => {\n\t\t\t\t\t\t\t\tconst money = finiteOrNull(row.apportionedRevenue)\n\t\t\t\t\t\t\t\treturn money === null ? null : formatMoney(money, data.currency ?? null)\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\trender: (row) => {\n\t\t\t\t\t\t\t\t// finiteOrNull, not `=== null`. An older API route omits these fields\n\t\t\t\t\t\t\t\t// entirely, so they arrive as UNDEFINED — which slipped past a null check\n\t\t\t\t\t\t\t\t// and rendered \"NaN% of tracked sales\" in every Studio between publishing\n\t\t\t\t\t\t\t\t// this package and deploying the sites. That window is the normal state\n\t\t\t\t\t\t\t\t// of this repo, not an edge case.\n\t\t\t\t\t\t\t\tconst share = finiteOrNull(row.revenueShare)\n\t\t\t\t\t\t\t\t// Zero is not a measurement here. At a fifth of traffic captured, a channel\n\t\t\t\t\t\t\t\t// with four real sales and no tracked one is the ordinary case, and it was\n\t\t\t\t\t\t\t\t// rendering a currency-formatted \"$0.00 · 0% of tracked sales\" — a measured\n\t\t\t\t\t\t\t\t// zero manufactured from lossy data, which is the one thing the metric types\n\t\t\t\t\t\t\t\t// in this package exist to forbid.\n\t\t\t\t\t\t\t\tif (share === null || share === 0) return <Text size={1} muted>—</Text>\n\t\t\t\t\t\t\t\tconst money = finiteOrNull(row.apportionedRevenue)\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<Stack space={1}>\n\t\t\t\t\t\t\t\t\t\t{money !== null && <Text size={1}>{formatMoney(money, data.currency ?? null)}</Text>}\n\t\t\t\t\t\t\t\t\t\t<Text size={0} muted>{formatPercent(share, 0)} of tracked sales</Text>\n\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t]}\n\t\t\t\t/>\n\n\t\t\t\t{/* The column is deliberately allowed to be a column of dashes — a null is not a zero,\n\t\t\t\t and this package would rather look uncomfortable than round an absence down. But\n\t\t\t\t the paragraph that explains the column was gated on the SUCCESS case, so in exactly\n\t\t\t\t the state where every cell is a dash there was no explanation anywhere on screen:\n\t\t\t\t the reason went to `notices` at the foot of the panel, where it can also be folded\n\t\t\t\t behind \"N more caveats\". A column that is meant to be uncomfortable is only honest\n\t\t\t\t if it says why where it sits. */}\n\t\t\t\t{!(data.splitIsSound === true && finiteOrNull(data.actualRevenue) !== null) && (\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\tRevenue is not split across sources here.{' '}\n\t\t\t\t\t\t{finiteOrNull(data.actualRevenue) === null\n\t\t\t\t\t\t\t? 'Your orders carry no amount for this period, so there is no total to divide.'\n\t\t\t\t\t\t\t: 'Google Analytics attributed too few of these sales to a source for the division to mean anything.'}\n\t\t\t\t\t\t{' '}The sessions and engagement columns are unaffected.\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\n\t\t\t\t{data.splitIsSound === true && finiteOrNull(data.actualRevenue) !== null && (\n\t\t\t\t\t// Says exactly what was combined and what was estimated. The money in that column\n\t\t\t\t\t// is Sanity's real total spread across GA4's split — right in scale, and derived,\n\t\t\t\t\t// which is a different claim from measured. Saying so here is what makes the\n\t\t\t\t\t// column usable rather than another figure to distrust.\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t{/* The denominator the money actually covers. `actualRevenue` sums only orders\n\t\t\t\t\t\t carrying an amount and `actualOrders` counts all of them, so this read\n\t\t\t\t\t\t \"Revenue is US$3,150 from 69 orders\" where the money covered eleven — two\n\t\t\t\t\t\t different sets of orders joined by the word \"from\". */}\n\t\t\t\t\t\tRevenue is {formatMoney(finiteOrNull(data.actualRevenue) ?? 0, data.currency ?? null)} from{' '}\n\t\t\t\t\t\t{finiteOrNull(data.actualOrders) === null\n\t\t\t\t\t\t\t? 'your orders'\n\t\t\t\t\t\t\t: (data.ordersMissingTotal ?? 0) > 0\n\t\t\t\t\t\t\t\t? `the ${formatCount((data.actualOrders as number) - (data.ordersMissingTotal as number))} of ${formatCount(data.actualOrders as number)} orders that carry an amount`\n\t\t\t\t\t\t\t\t: `${formatCount(data.actualOrders as number)} orders`}{' '}in Sanity,\n\t\t\t\t\t\tsplit across the {formatCount(data.trackedPurchases ?? 0)} purchase\n\t\t\t\t\t\t{data.trackedPurchases === 1 ? '' : 's'} Google Analytics attributed to a source. Google\n\t\t\t\t\t\tAnalytics sees a fraction of your traffic, so treat this as a proportion with a real total\n\t\t\t\t\t\tbehind it rather than measured takings per channel. Two things to hold in mind: it credits\n\t\t\t\t\t\twhichever source a buyer arrived from on the visit they bought, which for a considered\n\t\t\t\t\t\tpurchase is often a bookmark rather than the blog that first sent them; and its loss is not\n\t\t\t\t\t\tperfectly even across channels — ad-blocking runs high among designers, so earned referrals\n\t\t\t\t\t\tand direct visits are likelier to be under-credited than paid clicks.\n\t\t\t\t\t\t{finiteOrNull(data.shownPurchases) !== null && (data.shownPurchases ?? 0) < (data.trackedPurchases ?? 0) && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{' '}The rows above account for {formatCount(data.shownPurchases ?? 0)} of those sales; the rest\n\t\t\t\t\t\t\t\tcame from sources outside this list, so this column does not add up to the whole.\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t</div>\n\n\n\t\t\t{/* WHERE THEY ARRIVED, beside where they came from.\n\t\t\t\n\t\t\t Moved from the Journey tab, where it sat only because GA4 returns it on the funnel's\n\t\t\t query. A landing page is the same grain as a referrer — one row per entry point, ranked\n\t\t\t by sessions, with the identical engagement column — so this panel now answers \"from\n\t\t\t where, and onto what\" on one screen, and the funnel tab is the funnel and its outcomes.\n\t\t\t\n\t\t\t Entries, not exits: GA4 exposes landingPage and has never had an exits metric. A\n\t\t\t previous version of this table queried one and rendered nothing, ever. */}\n\t\t\t{(data.topLandingPages?.length ?? 0) > 0 && (\n\t\t\t\t<Section\n\t\t\t\t\ttitle=\"Where sessions began\"\n\t\t\t\t\ttone=\"secondary\"\n\t\t\t\t\tsubtitle=\"The page a visit started on, busiest first.\"\n\t\t\t\t>\n\t\t\t\t\t<LandingPagesTable rows={data.topLandingPages ?? []} />\n\t\t\t\t</Section>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n/** Journey — a funnel, drawn as a tracked sequence or as independent totals, whichever the report used. */\n/**\n * How many of a segment's entrants reached the second rung.\n *\n * The single most comparable number between segments: every segment has a first step, the rate is\n * already a proportion so segments of wildly different sizes sit on one scale, and it is where a\n * foundry's funnel actually breaks. Null when the segment has fewer than two rungs or GA4 gave no\n * rate — never zero, which would rank an unmeasured segment as the worst performer.\n *\n * @param steps - one segment's rungs, in funnel order\n */\nexport function firstStepRate(steps: JourneyStep[]): number | null {\n\tconst second = steps[1]\n\tif (!second) return null\n\tconst rate = second.conversionFromPrevious\n\treturn typeof rate === 'number' && Number.isFinite(rate) ? rate : null\n}\n\n/**\n * A sentence naming the gap between the best and worst segment, or null when there is not one.\n *\n * Only fires at a MULTIPLE, not a margin. Segments always differ a little, and a tool that\n * remarks on every difference teaches the reader to stop reading its remarks; three times apart is\n * the point at which the undivided funnel above stops describing either group.\n *\n * Both sides need a real denominator. At a foundry's volumes a segment of forty users can produce\n * any rate at all, and \"tablet converts nine times better than desktop\" off six people is the kind\n * of confident nonsense this package exists not to print.\n *\n * @param segments - every segment, in any order\n */\nexport function spread(segments: JourneySegment[]): string | null {\n\tconst MIN_USERS = 200\n\tconst RATIO = 3\n\n\tconst rated = segments\n\t\t.map((segment) => ({\n\t\t\tlabel: segment.label,\n\t\t\trate: firstStepRate(segment.steps),\n\t\t\tusers: metricSortValue(segment.steps[0]?.count) ?? 0,\n\t\t}))\n\t\t.filter((row): row is { label: string; rate: number; users: number } =>\n\t\t\trow.rate !== null && row.rate > 0 && row.users >= MIN_USERS)\n\n\tif (rated.length < 2) return null\n\tconst sorted = [...rated].sort((a, b) => b.rate - a.rate)\n\tconst best = sorted[0] as { label: string; rate: number; users: number }\n\tconst worst = sorted[sorted.length - 1] as { label: string; rate: number; users: number }\n\tconst ratio = best.rate / worst.rate\n\tif (ratio < RATIO) return null\n\n\treturn `${worst.label} reaches the second step at ${formatPercent(worst.rate, 2)} against `\n\t\t+ `${best.label}'s ${formatPercent(best.rate, 2)} — about ${Math.round(ratio)} times worse, on `\n\t\t+ `${formatCount(worst.users)} visitors. The combined funnel above averages the two and describes neither.`\n}\n\n/**\n * Why a table is shorter than reality, in one line.\n *\n * Two different causes used to produce two separate notices two sentences apart: `rowsTruncated`\n * (GA4 held more rows than the query's limit returned) rendered a quiet line under the table, and\n * `rowsWithheld` (GA4 suppressed low-count rows for privacy) rendered a second amber Caveat card\n * below it. The distinction is real and worth keeping in the wording — the reader's takeaway is\n * identical either way, and the same fact in two registers two lines apart is how a reader learns\n * to stop reading amber.\n *\n * Returns undefined when the list IS complete, so nothing is said. Silence is the honest default\n * here: a note that always renders carries no information.\n *\n * @param truncated - GA4 held more rows than it returned\n * @param withheld - GA4 suppressed low-count rows for privacy\n * @param tail - what the missing rows mean for this particular table\n */\nexport function shortListNote(truncated: boolean, withheld: boolean, tail: string): string | undefined {\n\tif (!truncated && !withheld) return undefined\n\tconst cause = truncated && withheld\n\t\t? 'GA4 returned only its top rows, and withheld others with too few people to report'\n\t\t: truncated\n\t\t\t? 'GA4 returned only its top rows'\n\t\t\t: 'GA4 withheld rows with too few people to report'\n\treturn `${cause}, so this list is shorter than reality. ${tail}`\n}\n\n/**\n * The rank line under an exact figure, or nothing.\n *\n * Drawn from the daily series already in the envelope, so it costs no request and — because orders\n * and revenue come from the foundry's own records — inherits none of GA4's loss. Offered only for\n * the EXACT quantities: ranking a GA4 figure would rank the instrument's mood alongside the\n * business.\n *\n * Returns null rather than a hedge when there are too few whole weeks, so a short range simply\n * says less instead of saying something weaker.\n *\n * @param series - the cross-source daily rows\n * @param pick - which exact quantity to rank\n */\nexport function rankLine(series: CrossSourceDay[] | undefined, pick: (d: CrossSourceDay) => number | null): string | null {\n\tif (!series || series.length === 0) return null\n\tconst ranked = weeklyRank(series.map((d) => ({ date: d.date, value: pick(d) })))\n\treturn ranked ? describeRank(ranked) : null\n}\n\n\n/**\n * Whether every counted order carries an amount, so a daily revenue series is safe to draw.\n *\n * The server fills a day's revenue as `revenueByDate[date] ?? 0`, which is right when every order\n * has a total and a catastrophe when they do not: a day with a real sale and no recorded amount\n * becomes a MEASURED ZERO, and the timeline draws the tick that means \"we looked, and nothing\n * happened\" on a day something did. At Darden that is 58 of 69 days.\n *\n * So the revenue row is drawn only when the two counts agree. Orders are exact regardless, which is\n * why they are now always drawn instead.\n *\n * @param data - the measurement-health payload\n */\nexport function revenueCoversEveryOrder(data: MeasurementHealthData): boolean {\n\tconst counted = metricSortValue(data.orders)\n\tconst withTotal = finiteOrNull(data.ordersWithTotal)\n\tif (counted === null || withTotal === null) return false\n\treturn counted === withTotal\n}\n\n\nexport function JourneyPanel({ data }: { data: JourneyData }): React.ReactElement {\n\tconst segments = data.segments ?? []\n\n\t// Step order comes from the widest segment, so a segment GA4 stopped reporting partway down\n\t// leaves a gap rather than shortening the chart for everyone.\n\tconst spine = [...segments].sort((a, b) => b.steps.length - a.steps.length)[0]?.steps ?? []\n\n\t// The funnel draws EVERYONE. Per-segment shapes are in the survival chart above it, where they\n\t// can be compared side by side; a funnel that silently became one device's funnel, with only a\n\t// pressed button to say so, was the thing that made the comparison invisible.\n\tconst allSteps = data.steps ?? []\n\n\t// Steps this site does not instrument are hidden rather than drawn as empty rails. An\n\t// uninstrumented rung told the reader nothing except that the funnel had a hole in it, and it\n\t// sat between two real steps implying a drop-off that was never measured. They are counted in a\n\t// line beneath instead, so the omission is still stated but does not masquerade as a stage.\n\tconst shown = allSteps.filter((step) => step.count.status !== 'unavailable')\n\tconst hiddenSteps = allSteps.filter((step) => step.count.status === 'unavailable')\n\n\t// The chart takes plain numbers; the unavailable steps have already been removed above, so the\n\t// narrowing here cannot drop a measured value.\n\t// `partial` travels with the count. It used to stop here: the metric's status, its coveredFrom\n\t// and its note were all dropped at this line, so a step instrumented half-way through the window\n\t// reached the chart indistinguishable from one measured throughout — and the chart then printed\n\t// a share of it against a full-window denominator.\n\tconst stages = shown.flatMap((step) =>\n\t\tstep.count.status === 'unavailable'\n\t\t\t? []\n\t\t\t: [{\n\t\t\t\tkey: step.key,\n\t\t\t\tlabel: step.label,\n\t\t\t\tvalue: step.count.value,\n\t\t\t\tconversionFromPrevious: step.conversionFromPrevious,\n\t\t\t\t// The device split, subdividing this rung's bar rather than drawn as a second chart.\n\t\t\t\t// Only counts that are present and whole travel: a segment GA4 stopped reporting is\n\t\t\t\t// left out of the split rather than drawn as a shrunken share of one.\n\t\t\t\t...(segments.length > 0\n\t\t\t\t\t? {\n\t\t\t\t\t\tsegments: segments.flatMap((segment) => {\n\t\t\t\t\t\t\tconst cell = segment.steps.find((s) => s.key === step.key)\n\t\t\t\t\t\t\tconst value = cell ? metricSortValue(cell.count) : null\n\t\t\t\t\t\t\treturn value === null ? [] : [{ key: segment.key, label: segment.label, value }]\n\t\t\t\t\t\t}),\n\t\t\t\t\t}\n\t\t\t\t\t: {}),\n\t\t\t\t...(step.count.status === 'partial' && step.count.coveredFrom\n\t\t\t\t\t? { partial: { from: formatDay(step.count.coveredFrom) } }\n\t\t\t\t\t: {}),\n\t\t\t}],\n\t)\n\n\n\tconst tracked = data.measurement === 'sequence'\n\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t{/* A tracked funnel is not a caveat, so it is not drawn as one. The fallback still is:\n\t\t\t independent totals invite exactly the reading — \"this many people dropped out here\"\n\t\t\t — that they cannot support. */}\n\t\t\t<Card padding={3} radius={2} tone={tracked ? 'transparent' : 'caution'} border>\n\t\t\t\t<Stack space={2}>\n\t\t\t\t\t<Text size={1} weight=\"medium\">\n\t\t\t\t\t\t{/* \"Independent per-step totals\" was three words, none of which mean anything to\n\t\t\t\t\t\t a foundry owner, in the position a heading goes. The distinction it draws is\n\t\t\t\t\t\t real and load-bearing — it forbids reading the gaps as drop-off — so the\n\t\t\t\t\t\t wording changes and the claim does not. */}\n\t\t\t\t\t\t{tracked ? 'Tracked path' : 'Not a tracked path'}\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text size={1} muted={tracked}>{data.approximationNote}</Text>\n\t\t\t\t</Stack>\n\t\t\t</Card>\n\n\t\t\t{/* Every segment at once, instead of a control that swapped between them.\n\t\t\t\n\t\t\t The radiogroup this replaces put the most actionable fact in the data — mobile\n\t\t\t reaching the second step at a ninth of desktop's rate — behind clicking, remembering,\n\t\t\t and clicking back. A comparison the reader has to hold in their head is not a\n\t\t\t comparison the tool made. Its buttons also printed a bare rate to two decimals with\n\t\t\t no denominator and no floor: the same claim spread() refuses below 200 users,\n\t\t\t arriving through the back door with more implied precision. */}\n\t\t\t{/* ONE chart, not two. The device split now subdivides the funnel's own bars.\n\t\t\t\n\t\t\t A separate survival chart drew the same segments as normalised shares on its own axis,\n\t\t\t directly above a funnel drawing the pooled counts — two pictures of one fact. The\n\t\t\t share-only one could not print a count, and at rungs of 23, 12 and 7 the count is the\n\t\t\t only figure that survives; its denominator floor also silenced it at exactly those\n\t\t\t rungs, which are the ones about money. The funnel already prints every count, withholds\n\t\t\t every ratio it cannot support, and handles the part-window case. */}\n\n\t\t\t{segments.length >= 2 && (\n\t\t\t<ChartData\n\t\t\t\tlabel=\"Show the figures behind this\"\n\t\t\t\trows={segments}\n\t\t\t\trowKey={(segment) => segment.key}\n\t\t\t\tcolumns={[\n\t\t\t\t\t{\n\t\t\t\t\t\tkey: 'segment',\n\t\t\t\t\t\tlabel: data.segmentDimension ?? 'Segment',\n\t\t\t\t\t\tsortValue: (segment: JourneySegment) => segment.label,\n\t\t\t\t\t\trender: (segment: JourneySegment) => segment.label,\n\t\t\t\t\t},\n\t\t\t\t\t...spine.map((step) => ({\n\t\t\t\t\t\tkey: step.key,\n\t\t\t\t\t\tlabel: step.label,\n\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\tsortValue: (segment: JourneySegment) =>\n\t\t\t\t\t\t\tmetricSortValue(segment.steps.find((s) => s.key === step.key)?.count),\n\t\t\t\t\t\trender: (segment: JourneySegment) => {\n\t\t\t\t\t\t\tconst cell = segment.steps.find((s) => s.key === step.key)\n\t\t\t\t\t\t\tconst count = metricSortValue(cell?.count)\n\t\t\t\t\t\t\tif (count === null) return '—'\n\t\t\t\t\t\t\t// The rate AND its denominator. Dropping the\n\t\t\t\t\t\t\t// denominator here would reinstate the bare two-decimal rate the segment\n\t\t\t\t\t\t\t// buttons used to print — the claim spread() refuses, through the back door.\n\t\t\t\t\t\t\tconst index = spine.findIndex((sp) => sp.key === step.key)\n\t\t\t\t\t\t\tconst before = index > 0\n\t\t\t\t\t\t\t\t? metricSortValue(segment.steps.find((s) => s.key === spine[index - 1]!.key)?.count)\n\t\t\t\t\t\t\t\t: null\n\t\t\t\t\t\t\tconst rate = cell?.conversionFromPrevious ?? null\n\t\t\t\t\t\t\tconst showRate = index > 0 && rate !== null && before !== null && before >= MIN_RATE_DENOMINATOR\n\t\t\t\t\t\t\treturn showRate\n\t\t\t\t\t\t\t\t? `${formatCount(count)} · ${formatPercent(rate, 1)} of ${formatCount(before)}`\n\t\t\t\t\t\t\t\t: formatCount(count)\n\t\t\t\t\t\t},\n\t\t\t\t\t})),\n\t\t\t\t]}\n\t\t\t/>\n\t\t\t)}\n\n\t\t\t{spread(segments) && <Text size={1}>{spread(segments)}</Text>}\n\n\t\t\t<FunnelChart stages={stages} measurement={data.measurement ?? 'independent-totals'} />\n\n\t\t\t{(data.outcomes?.length ?? 0) > 0 && (\n\t\t\t\t<Section\n\t\t\t\t\ttitle=\"Other outcomes\"\n\t\t\t\t\t/* Beside the funnel, not inside it. An enquiry is an alternative ending, not a\n\t\t\t\t\t later stage — and until now the funnel ended at purchase, which scored the\n\t\t\t\t\t visitor who read three typeface pages and emailed as a drop-off. */\n\t\t\t\t\tsubtitle=\"Successful outcomes that are not a licence sale, counted over the same window as the funnel but not a step within it.\"\n\t\t\t\t>\n\t\t\t\t\t<div style={cardGrid}>\n\t\t\t\t\t\t{(data.outcomes ?? []).map((outcome) => (\n\t\t\t\t\t\t\t<Card key={outcome.key} padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t\t\t<Label size={1} muted>{outcome.label}</Label>\n\t\t\t\t\t\t\t\t\t<MetricFigure metric={outcome.count} label={outcome.label} size={4} />\n\t\t\t\t\t\t\t\t\t<Text size={0} muted>{outcome.note}</Text>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</Section>\n\t\t\t)}\n\n\t\t\t{hiddenSteps.length > 0 && (\n\t\t\t\t<Text size={1} muted>\n\t\t\t\t\tNot shown: {hiddenSteps.map((step) => step.label.toLowerCase()).join(', ')} — not instrumented on\n\t\t\t\t\tthis site, so there is no figure to place in the funnel.\n\t\t\t\t</Text>\n\t\t\t)}\n\n\t\t</div>\n\t)\n}\n\n/**\n * Landing pages, as a table.\n *\n * Lifted out of `JourneyPanel` so the two supporting blocks on that tab can sit in one grid without\n * a hundred lines of column definitions between them. Behaviour is unchanged.\n *\n * @param rows - landing pages, busiest first\n */\nfunction LandingPagesTable({ rows }: { rows: LandingPage[] }): React.ReactElement {\n\treturn (\n\t\t<SortableTable<LandingPage>\n\t\t\t\t\t\tcaption=\"Landing pages by sessions\"\n\t\t\t\t\t\tinitialSort=\"sessions\"\n\t\t\t\t\t\trows={rows}\n\t\t\t\t\t\trowKey={(page) => page.path}\n\t\t\t\t\t\tfilterOn={(page) => page.path}\n\t\t\t\t\t\tfilterPlaceholder=\"Filter pages\"\n\t\t\t\t\t\texportName=\"landing-pages\"\n\t\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'path',\n\t\t\t\t\t\t\t\tlabel: 'Page',\n\t\t\t\t\t\t\t\tsortValue: (page) => page.path,\n\t\t\t\t\t\t\t\t// The comment here used to describe this fix without applying it: it said\n\t\t\t\t\t\t\t\t// the foundry's own URLs were the one table that did not link while\n\t\t\t\t\t\t\t\t// referrer hosts did, and then rendered plain text. A comment asserting\n\t\t\t\t\t\t\t\t// something the code does not do is worse than no comment.\n\t\t\t\t\t\t\t\trender: (page) => (\n\t\t\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\t\t\t<a href={page.path} target=\"_blank\" rel=\"noopener noreferrer\" style={sourceLink}>{page.path}</a>\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'sessions',\n\t\t\t\t\t\t\t\tlabel: 'Sessions',\n\t\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t\tsortValue: (page) => page.sessions,\n\t\t\t\t\t\t\t\trender: (page) => <Text size={1}>{formatCount(page.sessions)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'engagement',\n\t\t\t\t\t\t\t\tlabel: 'Engaged',\n\t\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t\tsortValue: (page) => finiteOrNull(page.engagementRate),\n\t\t\t\t\t\t\t\texportValue: (page) => { const r = finiteOrNull(page.engagementRate); return r === null ? null : formatPercent(r, 0) },\n\t\t\t\t\t\t\t\t// Volume alone could not distinguish a page that delivers 200 arrivals\n\t\t\t\t\t\t\t\t// which leave from one that feeds the shop.\n\t\t\t\t\t\t\t\trender: (page) => finiteOrNull(page.engagementRate) === null\n\t\t\t\t\t\t\t\t\t? <Text size={1} muted>—</Text>\n\t\t\t\t\t\t\t\t\t: <Text size={1}>{formatPercent(page.engagementRate as number, 0)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\t)\n}\n\n/**\n * The catalogue's own orders-per-view: every family's sales over every family's views.\n *\n * The benchmark was the MEDIAN FAMILY's rate, and that was wrong twice over. It filtered to rates\n * above zero, so a family viewed four hundred times that has never sold — the single most useful\n * row, and the one the column was built to surface — was excluded from the benchmark AND rendered\n * as a dash. And at seven orders a quarter more than half the catalogue sells nothing in a window,\n * so the median is zero and an index against it is undefined for everyone.\n *\n * A pooled rate has neither problem: it is positive whenever anything sold at all, a family with no\n * sales scores an honest zero against it, and it is the benchmark the reader would name themselves\n * — \"against how the catalogue as a whole converts\".\n *\n * Returns null when nothing sold or nothing was viewed, because there is then no catalogue rate to\n * compare against and an index would be invented.\n *\n * @param rows - every family in the window\n */\n/**\n * Views a family needs before its sales-per-view is comparable to anything.\n *\n * Thirty, matching the funnel's floor: below it one more order moves the index by more than a\n * third, so the figure describes a single buyer rather than how the family converts.\n */\nconst MIN_FAMILY_VIEWS = 30\n\nexport function catalogueRate(rows: Array<{ viewed?: MetricValue; bought?: MetricValue }>): number | null {\n\tlet views = 0\n\tlet sales = 0\n\tfor (const row of rows) {\n\t\t// Whole-window views only. `metricSortValue` returns the value for `partial` as well as `ok`,\n\t\t// so a family whose view count covers thirteen days of a thirty-one-day range was pooled with\n\t\t// families covering all of it — and the benchmark every OTHER family is measured against was\n\t\t// itself computed across a mixture of windows.\n\t\tif (row.viewed?.status !== 'ok') continue\n\t\tconst viewed = metricSortValue(row.viewed)\n\t\tconst bought = metricSortValue(row.bought)\n\t\t// Both sides or neither: a family GA4 has no view count for cannot contribute its sales to a\n\t\t// rate, or the pooled figure would be sales from families whose views are missing.\n\t\tif (viewed === null || bought === null) continue\n\t\tviews += viewed\n\t\tsales += bought\n\t}\n\treturn views > 0 && sales > 0 ? sales / views : null\n}\n\n/**\n * A family's orders-per-view against the catalogue's, or null when not comparable.\n *\n * Zero is a RESULT, not an absence: a family that was viewed and never bought scores 0 and belongs\n * at the bottom of the sort, which is where a reader looking for a pricing or specimen problem\n * starts. Only a family with no usable rate at all returns null.\n *\n * @param row - the family's row\n * @param benchmark - the catalogue rate, or null when there was none\n */\nexport function buyRateIndex(\n\trow: { buyRate?: number | null; viewed?: MetricValue },\n\tbenchmark: number | null,\n): number | null {\n\tconst rate = row.buyRate\n\tif (benchmark === null || benchmark <= 0) return null\n\tif (rate === null || rate === undefined || !Number.isFinite(rate) || rate < 0) return null\n\n\t/*\n\t * The family's own views have to carry a ratio, not just the catalogue's.\n\t *\n\t * Every other small-sample figure here is gated by a number — the funnel withholds a rate below\n\t * thirty and says \"too few to give a rate\", the revenue split needs five attributed purchases and\n\t * a quarter of the order book. This column was gated only by a sentence in the blurb. On a\n\t * catalogue where a family's month is sixteen views and zero-or-one order, a single sale printed\n\t * \"4.9× catalogue\" in the same type as a figure computed on hundreds, and a reader would put that\n\t * typeface on the homepage on the strength of one order.\n\t *\n\t * The same floor the funnel uses, applied to the denominator that actually varies per row.\n\t */\n\tconst views = metricSortValue(row.viewed)\n\tif (views === null || views < MIN_FAMILY_VIEWS) return null\n\n\treturn rate / benchmark\n}\n\n/**\n * Revenue divided by the orders that actually carried an amount.\n *\n * NOT by the full order count. At Darden 58 of 69 counted orders have no amount recorded, so\n * dividing the revenue sum by every order would report an average six times too low — a number\n * someone might price against. The denominator has to be the same population as the numerator.\n *\n * Inherits `partial` from the revenue figure, because an average over a sixth of the orders is an\n * average of that sixth and should not present as the catalogue's.\n *\n * @param data - the overview payload\n */\n/**\n * What a campaign's following days took, per thousand addresses it went to.\n *\n * Per thousand rather than per send because a foundry mails thousands against a handful of orders:\n * divided per send the figure is fractions of a cent and every campaign renders as the same zero.\n *\n * Null — never zero — when there is no revenue figure, no send count, or the window has not run,\n * so an unmeasured campaign is not ranked below one that genuinely sold nothing.\n *\n * @param campaign - one send, already carrying its following-days figures\n */\nexport function revenuePerThousandSent(campaign: EmailCampaign): number | null {\n\tif (campaign.revenueAfter === null || campaign.revenueAfter === undefined) return null\n\tif (!Number.isFinite(campaign.sent) || campaign.sent <= 0) return null\n\treturn (campaign.revenueAfter / campaign.sent) * 1000\n}\n\nexport function averageOrderValue(data: MeasurementHealthData): MetricValue {\n\t/*\n\t * A derived figure INHERITS the absence of what it was derived from.\n\t *\n\t * Both of these first returned `not_applicable` for any missing input — which on a site whose\n\t * route predates the fields renders \"Does not apply to this site\" over a dash, the precise\n\t * contradiction removed from `metricOr` two releases ago, reintroduced one layer up. The\n\t * version-skew test caught it, which is what that test is for. `metricOr` already knows how to\n\t * describe a field the route is too old to send; a derived figure should say the same thing.\n\t */\n\tconst revenueMetric = metricOr(data.revenue, OLDER_ROUTE)\n\tif (revenueMetric.status === 'unavailable') return revenueMetric\n\tconst revenue = metricSortValue(revenueMetric)\n\tconst orders = finiteOrNull(data.ordersWithTotal)\n\tif (revenue === null || orders === null || orders <= 0) {\n\t\treturn unavailable('not_applicable', 'No order in this range carried an amount to average')\n\t}\n\tconst average = revenue / orders\n\t// Partial is decided here rather than inherited from the revenue metric, because this figure has\n\t// its own denominator: whenever fewer orders carry an amount than were counted, the average is\n\t// over a subset and the reader has to be told which subset before comparing it to anything.\n\tconst counted = finiteOrNull(metricSortValue(metricOr(data.orders, OLDER_ROUTE)))\n\tconst overSubset = counted !== null && counted > orders\n\treturn overSubset || data.revenue.status === 'partial'\n\t\t? partial(average, '', `Averaged over the ${formatCount(orders)} of ${formatCount(counted ?? orders)} orders that carry an amount.`)\n\t\t: ok(average)\n}\n\n/**\n * How many visitors it takes to make one sale.\n *\n * Against VERCEL's visitors rather than GA4's sessions. The owner's instinct is orders-per-session,\n * but GA4 sees a fraction of the traffic here, so that ratio would flatter conversion by whatever\n * share is missing — the same mistake the buy-rate column was rebuilt to avoid. Vercel is the least\n * lossy denominator available, and it counts people rather than visits, which is the unit a sale\n * belongs to.\n *\n * @param data - the overview payload\n */\nexport function visitorsPerOrder(data: MeasurementHealthData): MetricValue {\n\tconst visitorMetric = metricOr(data.vercelVisitors, OLDER_ROUTE)\n\tconst orderMetric = metricOr(data.orders, OLDER_ROUTE)\n\tif (visitorMetric.status === 'unavailable') return visitorMetric\n\tif (orderMetric.status === 'unavailable') return orderMetric\n\tconst visitors = metricSortValue(visitorMetric)\n\tconst orders = metricSortValue(orderMetric)\n\tif (visitors === null || orders === null || orders <= 0) {\n\t\treturn unavailable('not_applicable', 'No order in this range to divide the visitors by')\n\t}\n\treturn ok(Math.round(visitors / orders))\n}\n\n/** Typeface interest — viewed, tested and bought per family. */\nexport function TypefaceInterestPanel({ data }: { data: TypefaceInterestData }): React.ReactElement {\n\t// The whole catalogue's orders-per-view, which every family is measured against.\n\tconst benchmark = catalogueRate(data.rows ?? [])\n\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t<Text size={1} muted>{data.interpretationNote}</Text>\n\t\t\t</Card>\n\n\t\t\t<div style={stackBlock}>\n\t\t\t\t<SectionTitle title=\"Engagement by typeface\" />\n\t\t\t\t<Text size={1} muted>\n\t\t\t\t\tSort by any column. “Sells vs catalogue” compares each family’s sales-per-view\n\t\t\t\t\tagainst the catalogue as a whole — 1.0× is average, and sorting up finds the families that\n\t\t\t\t\tare looked at and do not sell. It is a comparison between your families, not the share of\n\t\t\t\t\tvisitors who buy: Google Analytics sees only a fraction of the views, which moves every\n\t\t\t\t\tfamily together and so cancels out here. With one or two sales a family will still swing a\n\t\t\t\t\tlong way, so read it alongside the order counts beside it.\n\t\t\t\t\t{' '}Bought and Revenue come from your own orders, not from Google Analytics — do not scale\n\t\t\t\t\tthose up.\n\t\t\t\t\t{' '}A family needs a reasonable number of views before it gets a comparison at all;\n\t\t\t\t\tbelow that it says so rather than ranking one order against the catalogue.\n\t\t\t\t</Text>\n\t\t\t\t<SortableTable<TypefaceInterestRow>\n\t\t\t\t\tcaption=\"Engagement by typeface\"\n\t\t\t\t\tinitialSort=\"viewed\"\n\t\t\t\t\trows={data.rows ?? []}\n\t\t\t\t\trowKey={(row) => row.typeface}\n\t\t\t\t\tfilterOn={(row) => row.typeface}\n\t\t\t\t\tfilterPlaceholder=\"Filter typefaces\"\n\t\t\t\t\texportName=\"typeface-interest\"\n\t\t\t\t\ttruncatedNote={shortListNote(\n\t\t\t\t\t\tBoolean(data.rowsTruncated),\n\t\t\t\t\t\tBoolean(data.rowsWithheld),\n\t\t\t\t\t\t'A family missing from this table has not necessarily gone quiet — and for a foundry the long tail is most of the catalogue.',\n\t\t\t\t\t)}\n\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'typeface',\n\t\t\t\t\t\t\tlabel: 'Typeface',\n\t\t\t\t\t\t\tsortValue: (row) => row.typeface,\n\t\t\t\t\t\t\trender: (row) => <Text size={1}>{row.typeface}</Text>,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'viewed',\n\t\t\t\t\t\t\t// The source is in the HEADER, because the ribbon at the top of this tab\n\t\t\t\t\t\t\t// instructs the reader to multiply its figures by about five — and two of\n\t\t\t\t\t\t\t// these four columns come from the order book and are exact. Following that\n\t\t\t\t\t\t\t// instruction across the whole table multiplies real revenue fivefold. The\n\t\t\t\t\t\t\t// sentence that prevents it already exists on the licence block below; it was\n\t\t\t\t\t\t\t// simply never applied where the mistake is made.\n\t\t\t\t\t\t\tlabel: 'Viewed (GA4)',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => metricSortValue(row.viewed),\n\t\t\t\t\t\t\trender: (row) => <MetricFigure metric={row.viewed} label={`${row.typeface} viewed`} size={1} />,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'tested',\n\t\t\t\t\t\t\tlabel: 'Tested (GA4)',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => metricSortValue(row.tested),\n\t\t\t\t\t\t\trender: (row) => <MetricFigure metric={row.tested} label={`${row.typeface} tested`} size={1} />,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'bought',\n\t\t\t\t\t\t\t// Never folded. Zero sales is the finding, not an empty column.\n\t\t\t\t\t\t\talwaysShow: true,\n\t\t\t\t\t\t\tlabel: 'Bought (orders)',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => metricSortValue(row.bought),\n\t\t\t\t\t\t\trender: (row) => <MetricFigure metric={row.bought} label={`${row.typeface} bought`} size={1} />,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'revenue',\n\t\t\t\t\t\t\t// Same reason as Bought: an exact zero from the order book is a measurement.\n\t\t\t\t\t\t\talwaysShow: true,\n\t\t\t\t\t\t\tlabel: 'Revenue (orders)',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => metricSortValue(row.revenue),\n\t\t\t\t\t\t\texportValue: (row) => {\n\t\t\t\t\t\t\t\tconst value = metricSortValue(row.revenue)\n\t\t\t\t\t\t\t\treturn value === null ? null : formatMoney(value, data.currency ?? null)\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t// The column that lets a catalogue be ranked by what it is worth rather\n\t\t\t\t\t\t\t// than by unit count, where a $30 web licence and a $400 desktop family\n\t\t\t\t\t\t\t// were the same integer.\n\t\t\t\t\t\t\t// Only a plain `ok` takes the bare-text branch. A `partial` value kept its\n\t\t\t\t\t\t\t// number but lost the caution badge and the note explaining what it\n\t\t\t\t\t\t\t// covers, which every other numeric column in this table keeps.\n\t\t\t\t\t\t\trender: (row) => {\n\t\t\t\t\t\t\t\tconst revenue = metricOr(row.revenue, OLDER_ROUTE)\n\t\t\t\t\t\t\t\treturn revenue.status === 'ok'\n\t\t\t\t\t\t\t\t\t? <Text size={1}>{formatMoney(revenue.value, data.currency ?? null)}</Text>\n\t\t\t\t\t\t\t\t\t: <MetricFigure metric={revenue} label={`${row.typeface} revenue`} size={1} />\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t * Relative to the catalogue, NOT an absolute rate.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * This is Sanity orders over GA4 views: a complete numerator on a lossy\n\t\t\t\t\t\t\t * denominator. Printed as \"0.85%\" it invited the reading \"eight in a\n\t\t\t\t\t\t\t * thousand visitors buy this\", which is wrong by whatever multiple GA4 is\n\t\t\t\t\t\t\t * missing — and it MOVES when the tag breaks. When Darden's GA4 count fell\n\t\t\t\t\t\t\t * from 471 a day to 70, every family's printed rate multiplied by about\n\t\t\t\t\t\t\t * seven, and the families that looked best were the ones GA4 had stopped\n\t\t\t\t\t\t\t * seeing.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * What survives that is the ORDER. The loss is roughly uniform across\n\t\t\t\t\t\t\t * families, so it cancels in a comparison between them even as it wrecks\n\t\t\t\t\t\t\t * the absolute figure. Showing each family against the catalogue median\n\t\t\t\t\t\t\t * keeps the one question this column can answer — which families convert\n\t\t\t\t\t\t\t * better or worse than the rest — and drops the one it cannot.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tkey: 'buyRate',\n\t\t\t\t\t\t\tlabel: 'Sells vs catalogue',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t// The INDEX, so a row rendered as a dash cannot sort above a row with a value.\n\t\t\t\t\t\t\t// Sorting on the raw rate returned 0 — a real number — for a family whose\n\t\t\t\t\t\t\t// cell showed \"—\", so ascending put a block of dashes at the top of the\n\t\t\t\t\t\t\t// table, which is the one thing this file's header forbids.\n\t\t\t\t\t\t\tsortValue: (row) => buyRateIndex(row, benchmark),\n\t\t\t\t\t\t\texportValue: (row) => {\n\t\t\t\t\t\t\t\tconst index = buyRateIndex(row, benchmark)\n\t\t\t\t\t\t\t\treturn index === null ? null : `${index.toFixed(1)}x catalogue`\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\trender: (row) => {\n\t\t\t\t\t\t\t\tconst index = buyRateIndex(row, benchmark)\n\t\t\t\t\t\t\t\tif (index === null) {\n\t\t\t\t\t\t\t\t\t// Says which absence it is, like the funnel does. A dash alone made a\n\t\t\t\t\t\t\t\t\t// family too quiet to compare look the same as one the catalogue could\n\t\t\t\t\t\t\t\t\t// not be benchmarked against at all.\n\t\t\t\t\t\t\t\t\tconst views = metricSortValue(row.viewed)\n\t\t\t\t\t\t\t\t\tconst tooQuiet = views !== null && views < MIN_FAMILY_VIEWS\n\t\t\t\t\t\t\t\t\t// Three absences, not two. A part-window view count is neither \"too quiet\"\n\t\t\t\t\t\t\t\t\t// nor simply missing — it is a real number that cannot be a denominator\n\t\t\t\t\t\t\t\t\t// for orders read across the whole range.\n\t\t\t\t\t\t\t\t\tconst partWindow = row.viewed?.status === 'partial'\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<Text size={1} muted aria-label={`${row.typeface} not comparable`}>\n\t\t\t\t\t\t\t\t\t\t\t{partWindow\n\t\t\t\t\t\t\t\t\t\t\t\t? 'view tracking started mid-period'\n\t\t\t\t\t\t\t\t\t\t\t\t: tooQuiet ? 'too few views to compare' : '—'}\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif (index === 0) {\n\t\t\t\t\t\t\t\t\t// Named, not left as \"0.0×\". A family with views and no sales is the\n\t\t\t\t\t\t\t\t\t// row this column exists to surface.\n\t\t\t\t\t\t\t\t\treturn <Text size={1}>No sales</Text>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\t\t\t{index.toFixed(1)}× catalogue\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t// No Test rate column. It was tested ÷ viewed, rendered muted, from the two\n\t\t\t\t\t\t// columns immediately to its left — and typefaceInterest's own note says that\n\t\t\t\t\t\t// where a site maps several tester events onto the step (TDF names three) the\n\t\t\t\t\t\t// numerator is not a person count, so the ratio ranks page layout as much as\n\t\t\t\t\t\t// interest. A reader who wants it can see both terms side by side.\n\t\t\t\t\t]}\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t{(data.licences?.length ?? 0) > 0 && (\n\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t<SectionTitle title=\"How licences sell\" />\n\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\tFrom your orders, so exact. Bars are orders; the money beside them is that order\n\t\t\t\t\t\ttotal split evenly across the licences on it, so it is a share rather than a price.\n\t\t\t\t\t</Text>\n\t\t\t\t\t{/* FACETED BY TYPE, RUNGS IN LADDER ORDER.\n\t\t\t\t\t\n\t\t\t\t\t This was one ranked list of `type · tier` strings with term as a subtitle — which\n\t\t\t\t\t flattened a three-dimensional fact into one axis and then sorted that axis by\n\t\t\t\t\t VALUE, shredding the only ordering tier has. Tier is a price ladder; the shape a\n\t\t\t\t\t foundry reads off it is whether the money sits at the bottom rung or the top, and\n\t\t\t\t\t a sort by size cannot produce that shape at any zoom level.\n\t\t\t\t\t\n\t\t\t\t\t Worse for the question the caption asks: \"a tier that sells well at one year may\n\t\t\t\t\t not at perpetual\" put the two rows for one tier as far apart as their values\n\t\t\t\t\t happened to fall. They are adjacent now, which is the comparison.\n\t\t\t\t\t\n\t\t\t\t\t Bars share one scale across every facet, so a rung in Desktop is comparable with a\n\t\t\t\t\t rung in Web. */}\n\t\t\t\t\t<LicenceLadder rows={data.licences ?? []} currency={data.currency ?? null} />\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{/* Both completeness flags were computed by the server and drawn nowhere, so a table\n\t\t\t holding part of the catalogue was presented as the catalogue. */}\n\n\t\t\t{/* One apportionment note for the panel, not one per section. The licence table and the\n\t\t\t family table were each printing a near-identical sentence about even splitting,\n\t\t\t ~28 words apart, which read as a stutter and made the other notes look cheaper. */}\n\t\t\t{/* One block, not three sections. These are three caveats about the same revenue\n\t\t\t column; as direct children of the panel root each was separated by a full section\n\t\t\t break, so thirty pixels of type occupied a third of a screen and read as three\n\t\t\t regions. */}\n\t\t\t<div style={stackBlock}>\n\t\t\t\t{data.revenueIsApportioned && (\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t{/* \"Apportioned\" was the stall, and it was not even the risk — EVENLY is. A foundry\n\t\t\t\t\t\t that sold two weights on one invoice sees half the money against each, which is\n\t\t\t\t\t\t not how they booked it. */}\n\t\t\t\t\t\tAn order covering several families is split evenly between them, because the order\n\t\t\t\t\t\tdocuments carry no per-line value.\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t\t{/* Why the column sums short. `orders.ts` computes this figure with a comment saying it\n\t\t\t\t exists to stop the column summing to less than the range's revenue with nothing to\n\t\t\t\t explain it — and then it was surfaced nowhere. */}\n\t\t\t\t{finiteOrNull(data.unattributedRevenue) !== null && (data.unattributedRevenue as number) > 0 && (\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t{formatMoney(data.unattributedRevenue as number, data.currency ?? null)} of this range’s\n\t\t\t\t\t\ttakings is not in the column above — those orders name no family in this catalogue.\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t\t{/* And how much of the order book the column covers at all. The same money is reported as\n\t\t\t\t partial on Overview; this tab used to call it exact. */}\n\t\t\t\t{finiteOrNull(data.ordersMissingTotal) !== null && (data.ordersMissingTotal as number) > 0 && (\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t{formatCount(data.ordersMissingTotal as number)} order\n\t\t\t\t\t\t{data.ordersMissingTotal === 1 ? '' : 's'} in this range carry no amount, so the revenue\n\t\t\t\t\t\tcolumn does not cover {data.ordersMissingTotal === 1 ? 'it' : 'them'}.\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\n/** Sanity UI tone for each check status. Status is also always spelled out in text. */\nconst CHECK_TONE: Record<CheckStatus, 'positive' | 'caution' | 'critical' | 'default'> = {\n\tpass: 'positive',\n\twarn: 'caution',\n\tfail: 'critical',\n\tskipped: 'default',\n}\n\n/** Word shown alongside the tone, so status never depends on colour alone. */\nconst CHECK_WORD: Record<CheckStatus, string> = {\n\tpass: 'Pass',\n\twarn: 'Check',\n\tfail: 'Fail',\n\tskipped: 'Skipped',\n}\n\n/**\n * Diagnostics — what to fix before trusting anything else here.\n *\n * Deliberately the panel that still works with nothing configured: on a site without credentials\n * it is the only one that can say something useful, and it is the first thing worth opening once\n * credentials land.\n */\nexport function DiagnosticsPanel({ data }: { data: DiagnosticReport }): React.ReactElement {\n\t// Guarded like every other array the panels read. This one was missed in the pass that made\n\t// the rest skew-tolerant, and it is what crashed the Diagnostics tab: `checks` is absent from\n\t// an older route's response, and `undefined.filter` takes the whole tool down.\n\tconst checks = data.checks ?? []\n\tconst failing = checks.filter((c) => c.status === 'fail').length\n\tconst warning = checks.filter((c) => c.status === 'warn').length\n\n\t// Scoped to what these checks actually test. The previous wording — \"the figures in the other\n\t// panels can be taken at face value\" — was a blanket endorsement covering statistical validity,\n\t// sampling, small denominators and a funnel the tool itself calls an approximation. These are\n\t// plumbing checks. A pass means the wiring is sound, not that the numbers are.\n\tconst summary =\n\t\tchecks.length === 0\n\t\t\t? 'No checks ran. Nothing here has been verified either way.'\n\t\t\t: data.verdict === 'pass'\n\t\t\t\t? 'Configuration and credentials check out. That covers the wiring, not whether the figures are worth trusting — the caveats on each panel still apply.'\n\t\t\t\t// A verdict of `skipped` is not a failure. The server-side severity order was fixed\n\t\t\t\t// so an all-skipped run stops reporting as a problem, but this branch still fell\n\t\t\t\t// through to the failure sentence and printed \"0 failing, 0 worth a look. Panels\n\t\t\t\t// depending on these will be wrong or incomplete\" — alarming, and false. Checks skip\n\t\t\t\t// routinely: no Vercel project, no purchases in the window, no orders in thirty days.\n\t\t\t\t: failing === 0 && warning === 0\n\t\t\t\t\t? `Nothing is failing. ${checks.length - failing - warning} check${checks.length - failing - warning === 1 ? '' : 's'} could not run in this range — usually because there was nothing to check, not because something is wrong.`\n\t\t\t\t\t: `${failing} failing, ${warning} worth a look. Panels depending on these will be wrong or incomplete until they are resolved.`\n\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t<Card padding={3} radius={2} tone={CHECK_TONE[data.verdict] ?? 'default'} border>\n\t\t\t\t<Text size={1}>{summary}</Text>\n\t\t\t</Card>\n\n\t\t\t<div style={stackBlock}>\n\t\t\t\t{checks.map((item) => (\n\t\t\t\t\t<Card key={item.id} padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t<Flex align=\"center\" justify=\"space-between\" gap={3}>\n\t\t\t\t\t\t\t\t<Text size={1} weight=\"semibold\">{item.label}</Text>\n\t\t\t\t\t\t\t\t<Badge tone={CHECK_TONE[item.status] ?? 'default'} fontSize={0}>{CHECK_WORD[item.status] ?? item.status}</Badge>\n\t\t\t\t\t\t\t</Flex>\n\t\t\t\t\t\t\t<Text size={1} muted>{item.detail}</Text>\n\t\t\t\t\t\t\t{item.remedy && <Text size={1}>{item.remedy}</Text>}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Card>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n","/**\n * One time axis, every source stacked against it.\n *\n * This is the only view in the tool that answers a question no single source can: did the thing we\n * did move the thing we care about. A campaign goes out on Tuesday — did traffic rise, did revenue\n * follow, and did GA4 even see it. Vercel knows the traffic, Sanity knows the money, Mailchimp\n * knows the send date, and GA4 knows a lossy fraction of the behaviour in between.\n *\n * SMALL MULTIPLES, NOT A DUAL AXIS. Putting pageviews and revenue on one pair of axes requires\n * choosing a scale factor between them, and whatever is chosen manufactures a visual correlation\n * that the data did not claim — two lines can be made to cross, diverge or track by nothing more\n * than the ratio picked. Stacked rows sharing one x axis show the same co-movement and assert\n * nothing about relative magnitude, because each row carries its own y axis and its own units.\n *\n * COMPLETENESS IS DRAWN. A series from a source that misses things is dashed and carries a shaded\n * band up to its estimated true value; a complete source is a solid line. The reader learns which\n * numbers are facts and which are a fifth of the facts without being told twice.\n *\n * d3 is used for scales and path generation only — pure functions in, path strings out. No\n * d3-selection, so nothing here touches the DOM and the whole chart renders under\n * renderToStaticMarkup, which is how the tests exercise it.\n */\n\nimport React, { useCallback, useMemo, useRef, useState } from 'react'\nimport { Card, Stack, Text } from '@liiift-studio/sanity-ui-compat'\nimport { scaleUtc, scaleLinear } from 'd3-scale'\nimport { line as d3Line, area as d3Area, curveLinear } from 'd3-shape'\nimport { max as d3Max } from 'd3-array'\nimport { HoverCard, SPACE, formatCount, formatMoney, formatPercent } from './Figure'\n// Lives in Figure.tsx, which is where the shared drawing components belong, and is re-exported\n// here because this is where it was born and where its tests still import it from.\nexport { HoverCard } from './Figure'\nimport { MARKS, SERIES, mark, seriesFill, type SeriesKey } from './palette'\n\n/** One point on one series. `value` null where the source reported nothing for that day. */\nexport interface SeriesPoint {\n\tdate: string\n\tvalue: number | null\n}\n\n/** How a value should be written out. */\nexport type SeriesUnit = 'count' | 'money' | 'percent'\n\n/**\n * One row of the chart: one answer, with what a lossier source saw underneath it.\n *\n * A row shows a SINGLE line by default. Two peer lines make the reader reconcile before they get\n * an answer, and at a glance a foundry owner wants \"how much\", not \"here are two measurements that\n * disagree\". The disagreement is still drawn — as a filled region, and in full on hover — because\n * hiding it entirely would switch off the alarm: the 24 August collapse was visible precisely\n * because two lines came apart.\n */\n/**\n * The colour a row draws in.\n *\n * Explicit `color` wins; otherwise the upstream decides, so every row from one source reads as a\n * family and a caller adding a row cannot accidentally introduce a sixth hue. Vercel is the\n * complete count and gets the reference blue; GA4's two units share the warm pair; Sanity's\n * quantities split into orders and money by unit, because a foundry reads those as different\n * things even though they come from one place.\n *\n * @param source - the upstream the line came from\n * @param unit - what the row measures, which separates Sanity's two\n * @param explicit - a colour the caller named\n */\nexport function colorFor(source: Series['source'], unit: SeriesUnit, explicit?: SeriesKey): SeriesKey {\n\tif (explicit) return explicit\n\tif (source === 'Vercel') return 'vercel'\n\tif (source === 'Sanity') return unit === 'money' ? 'revenue' : 'orders'\n\t// GA4 and Mailchimp. Sessions are the unit GA4 reports natively; a percentage computed from it\n\t// keeps the same family.\n\treturn unit === 'count' ? 'ga4Pageviews' : 'ga4Sessions'\n}\n\n/**\n * The floating readout, anchored to the hovered day.\n *\n * Sits over the plot rather than under it. The values used to live in a row beneath the chart, so\n * reading a peak meant looking at the peak, then travelling to the bottom of the component, then\n * back — and on a tall stack of small multiples the two were far enough apart that the reader lost\n * which day they were on. Anchored to the crosshair, the number is where the eye already is.\n *\n * `pointer-events: none` throughout: the card follows the pointer across the plot and must never\n * become the thing under it, which would end the hover it exists to describe and flicker.\n *\n * It flips to the left of the crosshair in the right-hand third, so the last week of a range — the\n * part a reader looks at most — does not push the card off the pane.\n */\nexport interface Series {\n\tkey: string\n\tlabel: string\n\t/** Which upstream the LINE came from. */\n\tsource: 'GA4' | 'Vercel' | 'Sanity' | 'Mailchimp'\n\t/** Whether the line's source sees everything. Drives the stroke and the wording. */\n\tcomplete: boolean\n\t/**\n\t * Which series colour this row draws in.\n\t *\n\t * Optional, and falling back to the row's `source`, so a caller that adds a row gets a colour\n\t * consistent with every other row from the same upstream rather than an uncoloured one. Set it\n\t * explicitly only where the source does not decide the meaning — coverage is computed FROM GA4\n\t * and Vercel and belongs to neither.\n\t */\n\tcolor?: SeriesKey\n\tunit: SeriesUnit\n\t/**\n\t * How the row is drawn. Defaults to `line`.\n\t *\n\t * `events` is for a quantity that only exists on the days it happened — orders, revenue. Darden\n\t * does seven orders a quarter, and a monotone curve through those seven points drew a smooth\n\t * rise and fall across eighty-three days on which nothing occurred, with the curve's overshoot\n\t * putting non-zero revenue on days that had none. A stem per day says what a sale is: a discrete\n\t * event, on a date, of a size. Days with nothing draw nothing, which is the truth.\n\t */\n\tmark?: 'line' | 'events'\n\t/**\n\t * A fixed y domain, instead of scaling the row to its own peak.\n\t *\n\t * Small multiples normally scale each row to itself, which is right for quantities whose\n\t * absolute size is not comparable between rows. It is wrong for a proportion: a coverage row\n\t * auto-scaled to its own maximum would redraw 100% at whatever the best day happened to be, so\n\t * a site running at a flat 20% would show a full-height line and read as healthy.\n\t */\n\tdomain?: [number, number]\n\t/**\n\t * The same series in the previous equivalent window, aligned by day OFFSET rather than by date.\n\t *\n\t * Drawn as a quiet ghost behind the row. Without it every row is an unanchored silhouette: a\n\t * flat line at 60 and a flat line at 6,000 are the same picture, and \"traffic looks lumpy\" is an\n\t * impression nobody can falsify. The comparison envelope is already fetched and already handed\n\t * to the metric cards, so this costs a prop and no network.\n\t *\n\t * What it buys is bigger than a reference line. It turns the tool's most fragile asset — a dated\n\t * alarm produced by a heuristic with a 21-day floor and a percentile threshold — from something\n\t * the reader has to trust into something they can check: the coverage row's step reads against\n\t * last period's flat line whether or not the detector fired.\n\t */\n\tcomparison?: SeriesPoint[]\n\tpoints: SeriesPoint[]\n\t/**\n\t * What a lossier source saw of the same thing.\n\t *\n\t * Drawn as a filled region between the two, NOT as a symmetric uncertainty band. There is no\n\t * doubt about the traffic here: Vercel counted it server-side. What is uncertain is how much of\n\t * it the analytics could see, and the honest way to draw that is the area it missed — a\n\t * quantity, visible to scale, rather than a percentage on another tab.\n\t */\n\tshortfall?: {\n\t\tlabel: string\n\t\t/** The lossier source's colour. Falls back to its `source`, as the row's own does. */\n\t\tcolor?: SeriesKey\n\t\tsource: Series['source']\n\t\tpoints: SeriesPoint[]\n\t}\n}\n\n/** A dated event drawn through every row, e.g. a campaign send. */\nexport interface TimelineMarker {\n\tdate: string\n\tlabel: string\n\tdetail?: string\n}\n\n/** Props for CrossSourceTimeline. */\nexport interface CrossSourceTimelineProps {\n\tseries: Series[]\n\tmarkers?: TimelineMarker[]\n\t/** ISO 4217 code for any `money` series. */\n\tcurrency?: string | null\n\t/**\n\t * Called when the reader drags across a span, with inclusive ISO dates.\n\t *\n\t * Time is the only dimension all four sources genuinely share, which makes it the only thing\n\t * worth linking on — a referrer row carries no date and a funnel step carries no source, so\n\t * value-based cross-filtering would need dimensions the reports do not request and could not\n\t * afford. Dragging a span and having every panel reflow to it is the whole of the cross-filter\n\t * this data model supports, and it costs one callback: the range state already drives\n\t * everything.\n\t */\n\tonBrush?: (start: string, end: string) => void\n}\n\n/**\n * Design width, in the same units as every other constant here.\n *\n * The chart previously used a `0 0 100 H` viewBox with `preserveAspectRatio=\"none\"` and a CSS\n * height equal to H — so the vertical scale was exactly 1 while the horizontal scale was\n * paneWidth/100, i.e. 4x to 12x. Under a non-uniform transform SVG scales the stroked OUTLINE, not\n * the stroke width, so line weight tracked slope, dash rhythm changed with both slope and pane\n * width, marker dots rendered as horizontal dashes, and `fontSize={AXIS_TYPE}` came out 3px tall and\n * stretched four- to twelve-fold sideways. Anamorphic letterforms, shipped to type designers.\n *\n * Worse, GUTTER was a pixel value pasted into a percentage space, so 52% of the chart was empty\n * margin and the data occupied 36% of the width.\n *\n * 760 matches TrendChart, which had this right all along. Uniform scaling, default\n * preserveAspectRatio, every constant in one space.\n */\nconst WIDTH = 760\n/** Height of one series row. */\n/** Space below each baseline, so a row's marks do not touch the row beneath. */\nconst BASELINE_GUTTER = 18\n/** Space under the last row for the shared date axis. */\nconst AXIS_HEIGHT = 26\n/** Left gutter for each row's own y-axis labels — 7% of the width, not 52%. */\nconst GUTTER = 52\nconst RIGHT_PAD = 12\nconst TOP_PAD = 22\n/** Type size inside the plot. A real value now that the scale is uniform. */\nconst AXIS_TYPE = 11\n/**\n * Height reserved above each row's plot for its label, in user units.\n *\n * The row's total height is this strip plus the data band plus the baseline gutter, and the band\n * now tracks the plot width — so reserving the strip costs the row's height rather than the data's.\n * An earlier version carved it out of a fixed row and left a 40px plot inside a 74px one.\n *\n * Enough for AXIS_TYPE plus its descender and a little air. The plot starts below it, so a row's\n * peak can reach the top of its scale without meeting its own name.\n */\nconst LABEL_STRIP = 16\n/**\n * Shortest span a brush may apply.\n *\n * Matches the chart's own render floor. Anything shorter narrows the range to a window the chart\n * cannot draw, which removes the control the reader would use to get back out.\n */\nconst MIN_BRUSH_DAYS = 3\n\n/** Format a value in its series' unit. */\nfunction formatValue(value: number, unit: SeriesUnit, currency: string | null | undefined): string {\n\tif (unit === 'money') return formatMoney(value, currency ?? null)\n\tif (unit === 'percent') return formatPercent(value, 1)\n\treturn formatCount(value)\n}\n\n/** Short date for an axis tick: `5 Sep`. */\nfunction tickLabel(date: Date): string {\n\treturn `${date.getUTCDate()} ${date.toLocaleString('en-GB', { month: 'short', timeZone: 'UTC' })}`\n}\n\n/**\n * Map a pointer's page X to a day index.\n *\n * Extracted from the component so it can be tested: `indexAt` needs a live\n * `getBoundingClientRect`, and these tests render to static markup with no DOM — which is exactly\n * why the bug this guards against shipped. The chart's viewBox tracks the measured width, and this\n * conversion was left dividing through the 760-unit SSR default, so on a 500px pane the right third\n * of pointer travel clamped to the last day and on a 900px pane the last sixth of days could not be\n * reached at all. The crosshair did not land under the cursor and a brush did not select the span\n * that was dragged.\n *\n * @param clientX - the pointer's viewport X\n * @param left - the chart box's viewport left edge\n * @param boxWidth - the chart box's rendered CSS width; 0 or less yields null\n * @param viewBoxWidth - the width the viewBox is currently set to, in user units\n * @param plotWidth - the drawable width inside the gutters, in user units\n * @param count - how many days are plotted\n */\nexport function dayIndexAt(\n\tclientX: number,\n\tleft: number,\n\tboxWidth: number,\n\tviewBoxWidth: number,\n\tplotWidth: number,\n\tcount: number,\n): number | null {\n\tif (count === 0 || boxWidth <= 0 || plotWidth <= 0) return null\n\tconst ratio = ((clientX - left) / boxWidth) * viewBoxWidth\n\tconst clamped = Math.max(GUTTER, Math.min(GUTTER + plotWidth, ratio))\n\tconst index = Math.round(((clamped - GUTTER) / plotWidth) * (count - 1))\n\treturn Math.max(0, Math.min(count - 1, index))\n}\n\n/** A sustained collapse in one source's coverage, as opposed to a standing shortfall. */\nexport interface CoverageIncident {\n\t/** First day of the run. */\n\tonset: string\n\t/** How many consecutive days it has lasted. */\n\tdays: number\n\t/** Typical coverage before it, 0 to 1. */\n\tbefore: number\n\t/** Typical coverage during it, 0 to 1. */\n\tduring: number\n\t/** Whether the run reaches the last measured day, i.e. it has not recovered. */\n\tongoing: boolean\n}\n\n/**\n * Tell a dated incident apart from a constant shortfall.\n *\n * The summary used to give a total and a single worst day, which cannot distinguish \"GA4 always\n * sees a fifth of the traffic\" from \"GA4 saw everything until the 24th, then almost nothing for ten\n * days\" — and the second is the founding case of this entire package. The two need opposite\n * responses: one is a measurement caveat to live with, the other is a fault with a date on it.\n *\n * The method is deliberately blunt, because the input is noisy at these volumes: take each day's\n * coverage, take the median as the range's normal, and look for a run of consecutive days sitting\n * far below that normal. A median is used rather than a mean so the incident itself does not define\n * the baseline it is measured against.\n *\n * @param days - coverage per day in date order, null on days that cannot be measured\n * @param dates - the matching dates, same length and order\n */\nexport function findCoverageIncident(\n\tdays: Array<number | null>,\n\tdates: string[],\n\t/**\n\t * The complete source's volume on each day, same length and order.\n\t *\n\t * Days too quiet to mean anything are excluded before anything is measured. Without this the\n\t * detector reads a ratio on an unbounded denominator: at a couple of dozen pageviews a day, a\n\t * quiet weekend with three pageviews and none of them seen is coverage 0.0, three of those in a\n\t * row clears the minimum run, and the loudest card in the tool prints a DATED accusation about a\n\t * day on which nothing happened. Someone then goes and pays to investigate it.\n\t *\n\t * The worst-day sentence twenty lines from here already learned this and wrote the reasoning\n\t * down; the superlative got a floor and the accusation did not, which is the wrong way round.\n\t */\n\tvolumes: Array<number | null> = [],\n): CoverageIncident | null {\n\t// A tenth of the busiest day, matching the floor the worst-day sentence uses.\n\tconst busiest = volumes.reduce<number>((best, v) => Math.max(best, v ?? 0), 0)\n\tconst floor = busiest > 0 ? Math.max(1, busiest * 0.1) : 0\n\tconst usable = days.map((value, i) => {\n\t\tconst volume = volumes[i]\n\t\tif (floor > 0 && (volume === null || volume === undefined || volume < floor)) return null\n\t\treturn value\n\t})\n\tdays = usable\n\n\tconst measured = days.filter((d): d is number => d !== null)\n\t// Three weeks of daily figures before this is worth attempting. Below that a \"run\" is as likely\n\t// to be a quiet fortnight as a fault, and naming a date carries more authority than the evidence.\n\tif (measured.length < 21) return null\n\n\tconst sorted = [...measured].sort((a, b) => a - b)\n\t/*\n\t * The 75th percentile, not the median.\n\t *\n\t * A median tolerates contamination only to half the sample, so once an outage covered more than\n\t * half the window the median WAS the outage: the threshold dropped to half the collapsed level,\n\t * the healthy days before it were never \"low\", and the function returned null. Detection got\n\t * quieter as the fault got worse, which is the opposite of what an alarm is for. A 46-day\n\t * collapse in a 90-day quarter — the default range — was silent.\n\t */\n\tconst normal = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]!\n\t// A source that never clears a tenth leaves no room for a fall to be detected against it. That\n\t// is a total shortfall, which the coverage row draws as a flat line near zero.\n\tif (normal <= 0.1) return null\n\n\tconst threshold = normal * 0.5\n\tconst MIN_RUN = 3\n\t// A run survives up to two consecutive unmeasured days. A null used to end it, and a null is\n\t// routine — a day with no traffic at all yields one — so a ten-day outage split into 6 and 3,\n\t// the longer half was reported, and the onset date named a day INSIDE the outage rather than\n\t// its start. That date is the one thing the reader acts on.\n\tconst MAX_GAP = 2\n\n\t// Every candidate, longest first. The \"needs a before\" rule used to be applied to the single\n\t// longest run and then ABORT the whole search, so a range that opens mid-outage reported\n\t// nothing at all — including any later incident it did have.\n\tconst runs: Array<{ start: number; end: number }> = []\n\tlet runStart: number | null = null\n\tlet gap = 0\n\tfor (let i = 0; i <= days.length; i++) {\n\t\tconst value = i < days.length ? days[i] : undefined\n\t\tconst low = value !== null && value !== undefined && value < threshold\n\t\tconst unmeasured = i < days.length && (value === null || value === undefined)\n\n\t\tif (low) {\n\t\t\tif (runStart === null) runStart = i\n\t\t\tgap = 0\n\t\t} else if (unmeasured && runStart !== null && gap < MAX_GAP) {\n\t\t\tgap += 1\n\t\t} else if (runStart !== null) {\n\t\t\t// The run ends at the last LOW day, not at the gap that followed it.\n\t\t\truns.push({ start: runStart, end: i - gap })\n\t\t\trunStart = null\n\t\t\tgap = 0\n\t\t}\n\t}\n\truns.sort((a, b) => (b.end - b.start) - (a.end - a.start))\n\n\tconst mean = (values: number[]) => values.reduce((sum, v) => sum + v, 0) / values.length\n\n\tfor (const run of runs) {\n\t\tif (run.end - run.start < MIN_RUN) continue\n\t\tconst during = days.slice(run.start, run.end).filter((d): d is number => d !== null)\n\t\tconst before = days.slice(0, run.start).filter((d): d is number => d !== null)\n\t\t// Without a before there is nothing to call this a CHANGE from — it is the range's normal.\n\t\tif (before.length < 3 || during.length === 0) continue\n\n\t\tconst onset = dates[run.start]\n\t\tif (!onset) continue\n\n\t\tconst beforeMean = mean(before)\n\t\tconst after = days.slice(run.end).filter((d): d is number => d !== null)\n\t\t/*\n\t\t * \"Recovered\" is a claim, and it now has to be earned.\n\t\t *\n\t\t * It used to mean only that the run did not touch the end of the array — so a null final day,\n\t\t * or one day back above the threshold, printed \"then recovered\" while coverage sat at half\n\t\t * its old level. This is the tool's one dated alarm and it could stand itself down on no\n\t\t * evidence. Recovery means measured days after the run, averaging back near where they were.\n\t\t */\n\t\tconst recovered = after.length >= MIN_RUN && mean(after) >= beforeMean * 0.8\n\n\t\treturn {\n\t\t\tonset,\n\t\t\tdays: run.end - run.start,\n\t\t\tbefore: beforeMean,\n\t\t\tduring: mean(during),\n\t\t\tongoing: !recovered,\n\t\t}\n\t}\n\n\treturn null\n}\n\nexport function CrossSourceTimeline({ series, markers = [], currency, onBrush }: CrossSourceTimelineProps): React.ReactElement | null {\n\tconst [hoverIndex, setHoverIndex] = useState<number | null>(null)\n\t// Whether to draw the constituent sources. Hover reveals them; so does keyboard focus and the\n\t// explicit toggle, because a chart whose detail exists only under a pointer is unreachable on a\n\t// phone and to anyone navigating by keyboard.\n\t/** Index the drag began at, or null when not dragging. */\n\tconst [brushAnchor, setBrushAnchor] = useState<number | null>(null)\n\t/**\n\t * The span being dragged, in day indices. Null when no drag is in flight.\n\t *\n\t * Deliberately NOT kept after release. Committing changes the range, which replaces the whole\n\t * series underneath these indices — so a selection left drawn would be positioned against days\n\t * that no longer exist, which is how it once produced NaN geometry. An earlier version of this\n\t * comment claimed the selection stays drawn to confirm the gesture; it never did.\n\t */\n\tconst [brushed, setBrushed] = useState<[number, number] | null>(null)\n\t/** A stable id for this mounted chart, so its SVG defs cannot collide with another instance's. */\n\tconst instanceId = React.useId().replace(/[^a-zA-Z0-9]/g, '')\n\t// Whether the reader arrived at this day by keyboard. Only then does the readout announce.\n\t//\n\t// The region is driven by `hoverIndex`, which a slow pointer sweep across ninety days changes\n\t// about ninety times — so a screen-reader user got the whole chart twice, once as the static\n\t// summary and once as an unthrottled stream. Keyboard stepping is the case the live region was\n\t// added for, and it changes one day at a time on purpose.\n\tconst [steppedByKeyboard, setSteppedByKeyboard] = useState(false)\n\n\t/** Whether the last drag was too short to apply, so the chart can say so instead of ignoring it. */\n\tconst [tooShort, setTooShort] = useState(false)\n\t/**\n\t * The chart's rendered width in CSS pixels, so the viewBox can track it.\n\t *\n\t * With a FIXED 760-unit viewBox and height:auto, the uniform scale is paneWidth/760 — so\n\t * `fontSize={11}` rendered at 5.8px in a 400px Studio pane and ~22px at full width, where the\n\t * row labels came out larger than the section heading above them. The chart's height swung the\n\t * same way, which made row height a function of how wide the pane happened to be.\n\t *\n\t * Making the viewBox equal the measured width pins the scale at exactly 1: type is 11px\n\t * everywhere, stroke weights are what they say, and only the x range responds to the pane.\n\t * WIDTH is the server-render default, and the first client measurement replaces it.\n\t */\n\tconst [measured, setMeasured] = useState(WIDTH)\n\tconst frameRef = useRef<SVGSVGElement | null>(null)\n\t/**\n\t * The chart element, as STATE as well as a ref.\n\t *\n\t * A ref alone cannot drive the measuring effect. The component returns null below three dates,\n\t * so on a mount with no data the ref is null when the effect runs — and with a stable dep array\n\t * the effect never runs again, leaving the chart pinned at the 760 default with no path out. A\n\t * callback ref makes attachment an observable event.\n\t */\n\tconst [frameNode, setFrameNode] = useState<SVGSVGElement | null>(null)\n\tconst attachFrame = useCallback((node: SVGSVGElement | null) => {\n\t\tframeRef.current = node\n\t\tsetFrameNode(node)\n\t}, [])\n\n\t// Every date any series reported, ascending. Built from the union so a series with a gap does\n\t// not shorten the axis for the others.\n\tconst dates = useMemo(() => {\n\t\tconst all = new Set<string>()\n\t\tfor (const s of series) for (const p of s.points) all.add(p.date)\n\t\treturn [...all].sort()\n\t}, [series])\n\n\tconst plotWidth = Math.max(120, measured - GUTTER - RIGHT_PAD)\n\n\t/*\n\t * Row height tracks the plot width, instead of being a constant.\n\t *\n\t * The chart measures itself to hold type at 11px — a typographic concern — and then left the\n\t * geometric one alone: a fixed 58px data band is 336x58 at a narrow pane and 1336x58 at a wide\n\t * one. Twenty-three to one. Every series flattens into a horizontal smear exactly where the\n\t * reader has the most room to look, and slope is the whole reason a time series is a line.\n\t *\n\t * Banked loosely toward a readable aspect and clamped at both ends, so a narrow pane does not\n\t * get a squashed row and a wide one does not get a chart that will not fit on screen.\n\t */\n\tconst bandHeight = Math.round(Math.max(58, Math.min(150, plotWidth / 7)))\n\tconst rowHeight = bandHeight + LABEL_STRIP + BASELINE_GUTTER\n\n\t// useLayoutEffect, and it measures once before the browser paints. With a plain effect the\n\t// first frame drew a 760-unit viewBox inside a real-width box with a fixed CSS height, and the\n\t// default preserveAspectRatio letterboxed it — a 400px pane rendered the whole chart at 52%\n\t// scale, centred in dead space, then snapped. That happened on every mount, so every tab switch\n\t// and every range change flashed it.\n\tReact.useLayoutEffect(() => {\n\t\tconst frame = frameNode\n\t\tif (!frame) return\n\n\t\t// Functional, so the effect does not depend on `measured`. Depending on it tore the\n\t\t// observer down and rebuilt it on every width change — avoidable churn, and the shape that\n\t\t// produces \"ResizeObserver loop completed with undelivered notifications\".\n\t\tconst apply = (width: number) => {\n\t\t\t// Rounded, so a sub-pixel resize does not churn the whole path set. A zero width is a\n\t\t\t// collapsed or hidden pane and carries no information, so the last good width stands.\n\t\t\tif (width <= 0) return\n\t\t\tconst next = Math.round(width)\n\t\t\tsetMeasured((current) => (Math.abs(next - current) > 1 ? next : current))\n\t\t}\n\n\t\tapply(frame.getBoundingClientRect().width)\n\t\tif (typeof ResizeObserver === 'undefined') return\n\n\t\tconst observer = new ResizeObserver((entries) => {\n\t\t\tconst width = entries[0]?.contentRect.width\n\t\t\tif (width !== undefined) apply(width)\n\t\t})\n\t\tobserver.observe(frame)\n\t\treturn () => observer.disconnect()\n\t}, [frameNode])\n\tconst height = TOP_PAD + series.length * rowHeight + AXIS_HEIGHT\n\n\tconst x = useMemo(() => {\n\t\tif (dates.length === 0) return null\n\t\treturn scaleUtc()\n\t\t\t.domain([new Date(`${dates[0]}T00:00:00Z`), new Date(`${dates[dates.length - 1]}T00:00:00Z`)])\n\t\t\t.range([GUTTER, GUTTER + plotWidth])\n\t}, [dates, plotWidth])\n\n\t/** Pointer position to a day index, in the shared viewBox space. */\n\tconst indexAt = useCallback(\n\t\t(clientX: number) => {\n\t\t\tconst frame = frameRef.current\n\t\t\tif (!frame || dates.length === 0) return null\n\t\t\tconst box = frame.getBoundingClientRect()\n\t\t\treturn dayIndexAt(clientX, box.left, box.width, measured, plotWidth, dates.length)\n\t\t},\n\t\t[dates.length, plotWidth, measured],\n\t)\n\n\t/**\n\t * Apply a span, if it is long enough to draw.\n\t *\n\t * The floor is three days because that is what the chart itself needs — it returns null below\n\t * three dates, and the panel gates the whole section the same way. A two-day brush was legal\n\t * and produced a window in which the timeline, its heading, its data table and the brush\n\t * affordance all disappeared: the instrument removed itself, leaving no way to widen back.\n\t */\n\tconst commit = useCallback(\n\t\t(from: number, to: number) => {\n\t\t\tif (!onBrush) return\n\t\t\t// Says so. This returned early and left nothing on screen, so the most likely mis-drag —\n\t\t\t// a short one — read as the chart ignoring the reader. The comment on the pointer-leave\n\t\t\t// handler rejects exactly this (\"a silent no-op that looks like success\") and then the\n\t\t\t// commit path did it.\n\t\t\tif (to - from < MIN_BRUSH_DAYS - 1) {\n\t\t\t\t// A click is not a failed drag. `to === from` means the pointer never moved — which is\n\t\t\t\t// the ordinary gesture for reading a day — and scolding it fired the refusal far more\n\t\t\t\t// often on non-gestures than on real mis-drags, training the reader to ignore the one\n\t\t\t\t// line of copy that carries a genuine one.\n\t\t\t\tsetTooShort(to > from)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetTooShort(false)\n\t\t\tconst start = dates[from]\n\t\t\tconst end = dates[to]\n\t\t\tif (start && end) onBrush(start, end)\n\t\t},\n\t\t[onBrush, dates],\n\t)\n\n\tconst onMove = useCallback(\n\t\t(event: React.PointerEvent<SVGSVGElement>) => {\n\t\t\tconst index = indexAt(event.clientX)\n\t\t\tif (index === null) return\n\t\t\tsetSteppedByKeyboard(false)\n\t\t\tsetHoverIndex(index)\n\t\t\t// Mid-drag: extend the selection rather than only moving the crosshair.\n\t\t\tif (brushAnchor !== null) {\n\t\t\t\tconst from = Math.min(brushAnchor, index)\n\t\t\t\tconst to = Math.max(brushAnchor, index)\n\t\t\t\t// Only when the span actually changes. A fresh array every pointer move re-rendered\n\t\t\t\t// the chart — and re-announced its live readout — on every pixel of a drag, where\n\t\t\t\t// setHoverIndex alone bails out on an unchanged index.\n\t\t\t\tsetBrushed((current) => (current && current[0] === from && current[1] === to ? current : [from, to]))\n\t\t\t}\n\t\t},\n\t\t[indexAt, brushAnchor],\n\t)\n\n\t/**\n\t * Which days get a date label, spread evenly from first to last.\n\t *\n\t * Always includes both ends. The count is driven by the measured plot width rather than fixed,\n\t * so a wide pane is not left with three labels across a metre of axis.\n\t *\n\t * ABOVE the early return, and it must stay there. It sat below, so any render that bailed —\n\t * fewer than three days, no series — called one hook fewer than the render before it, and React\n\t * throws \"Rendered fewer hooks than expected\", taking the whole Studio pane rather than the\n\t * chart. It was masked only because the panel happens to gate on the same three-day minimum,\n\t * which makes this component's own documented guard dead code and the safety one caller away.\n\t */\n\tconst tickIndexes = useMemo(() => {\n\t\tif (dates.length === 0) return []\n\t\tif (dates.length === 1) return [0]\n\t\tconst affordable = Math.max(2, Math.min(8, Math.floor(plotWidth / 90)))\n\t\tconst step = (dates.length - 1) / (affordable - 1)\n\t\tconst indexes = Array.from({ length: affordable }, (_, i) => Math.round(i * step))\n\t\t// Deduped: a short range can round two slots onto the same day, which would draw one label\n\t\t// on top of another and give the axis two identical ends.\n\t\treturn [...new Set(indexes)]\n\t}, [dates.length, plotWidth])\n\n\tif (dates.length < 3 || series.length === 0 || !x) return null\n\n\tconst hoveredDate = hoverIndex !== null ? dates[hoverIndex] : null\n\n\n\t// What the drawing says, in words. Serves a screen reader, anyone who cannot resolve the axis\n\t// type, and anyone reading a screenshot — the shaded region's whole argument was previously\n\t// available only as pixels.\n\tconst shapeOf = (row: Series) => {\n\t\tconst real = row.points.filter((p) => p.value !== null) as Array<{ date: string; value: number }>\n\t\tif (real.length === 0) return `${row.label}: no data`\n\t\tconst top = real.reduce((best, p) => (p.value > best.value ? p : best), real[0]!)\n\t\tconst total = real.reduce((sum, p) => sum + p.value, 0)\n\n\t\t// `role=\"img\"` prunes the descendants, so this sentence IS the chart for a screen reader —\n\t\t// it has to describe what is actually drawn. An event row draws stems on the days something\n\t\t// happened, so describing it as a shape \"peaking\" somewhere asserted a curve that does not\n\t\t// exist, and on an all-zero row it read \"peaking at $0\" beside a row drawing nothing.\n\t\tif (row.mark === 'events') {\n\t\t\tconst active = real.filter((p) => p.value !== 0)\n\t\t\tif (active.length === 0) {\n\t\t\t\treturn `${row.label} (${row.source}): nothing on any of the ${real.length} days measured`\n\t\t\t}\n\t\t\treturn `${row.label} (${row.source}): ${formatValue(total, row.unit, currency)} across `\n\t\t\t\t+ `${active.length} of ${real.length} days, the largest ${formatValue(top.value, row.unit, currency)} `\n\t\t\t\t+ `on ${tickLabel(new Date(`${top.date}T00:00:00Z`))}`\n\t\t}\n\n\t\treturn `${row.label} (${row.source}): ${formatValue(total, row.unit, currency)} in total, `\n\t\t\t+ `peaking at ${formatValue(top.value, row.unit, currency)} on ${tickLabel(new Date(`${top.date}T00:00:00Z`))}`\n\t}\n\n\tconst missed = series.flatMap((row) => {\n\t\tif (!row.shortfall) return []\n\t\tconst seen = new Map(row.shortfall.points.map((p) => [p.date, p.value]))\n\t\tlet gap = 0\n\t\tlet excess = 0\n\t\tlet worst: { date: string; ratio: number } | null = null\n\n\t\t// \"Widest\" needs a volume floor, because a ratio on an unbounded denominator is won by the\n\t\t// quietest day: one Vercel pageview and no GA4 scores a perfect 1.0 and beats a real\n\t\t// incident. At these volumes low-traffic days are routine, so without this the headline\n\t\t// sentence names a trivial Sunday most of the time. A tenth of the busiest day is the bar.\n\t\tconst busiest = row.points.reduce((best, p) => Math.max(best, p.value ?? 0), 0)\n\t\tconst floor = Math.max(1, busiest * 0.1)\n\n\t\tfor (const point of row.points) {\n\t\t\tif (point.value === null) continue\n\t\t\tconst saw = seen.get(point.date)\n\t\t\tif (saw == null) continue\n\t\t\tconst difference = point.value - saw\n\t\t\t// Signed sums kept apart. Adding them let days where GA4 counted MORE — a prefetch, a\n\t\t\t// double-firing tag — quietly cancel days where it counted less, so a site with both\n\t\t\t// problems reported no gap at all.\n\t\t\tif (difference >= 0) gap += difference\n\t\t\telse excess += -difference\n\t\t\tconst ratio = point.value > 0 ? 1 - saw / point.value : 0\n\t\t\tif (point.value >= floor && (!worst || ratio > worst.ratio)) worst = { date: point.date, ratio }\n\t\t}\n\n\t\t// Over-counting is its own finding, not the absence of one. It used to fall through the\n\t\t// `gap <= 0` guard and print nothing, so the failure mode with the loudest cause — a tag\n\t\t// installed twice — was the one the chart stayed silent about.\n\t\t// Both faults, separately, and never their difference. Printing `excess - gap` re-created\n\t\t// exactly the cancellation the accumulation above was split up to avoid: 1,100 over-counted\n\t\t// against 1,000 under-counted reported as \"100 more\". Returning early on it also suppressed\n\t\t// the under-count sentence — this tool's central figure — whenever a second, unrelated\n\t\t// defect happened to be larger.\n\t\tconst sentences: string[] = []\n\t\tif (excess > 0) {\n\t\t\tsentences.push(`${row.shortfall.source} counted ${formatValue(Math.round(excess), row.unit, currency)} more `\n\t\t\t\t+ `${row.label.toLowerCase()} than ${row.source} on some days, which usually means a tag firing twice `\n\t\t\t\t+ `rather than extra traffic.`)\n\t\t}\n\t\tif (gap <= 0) return sentences\n\n\t\t// An incident, if there is one. Stated BEFORE the total, because \"coverage fell on the 24th\n\t\t// and has not recovered\" is a different kind of fact from \"the gap was this big\" — the first\n\t\t// is a fault with a date, the second is a quantity to caveat.\n\t\tconst coverageByDay = row.points.map((point) => {\n\t\t\tif (point.value === null || point.value <= 0) return null\n\t\t\tconst saw = seen.get(point.date)\n\t\t\treturn saw == null ? null : saw / point.value\n\t\t})\n\t\tconst incident = findCoverageIncident(\n\t\t\tcoverageByDay,\n\t\t\trow.points.map((p) => p.date),\n\t\t\trow.points.map((p) => p.value),\n\t\t)\n\t\tif (incident) {\n\t\t\tsentences.push(\n\t\t\t\t`${row.shortfall.source} coverage fell from about ${formatPercent(incident.before, 0)} to `\n\t\t\t\t+ `${formatPercent(incident.during, 0)} on ${tickLabel(new Date(`${incident.onset}T00:00:00Z`))}`\n\t\t\t\t+ (incident.ongoing\n\t\t\t\t\t? `, and has stayed there for ${incident.days} days. That is a change on a date, not a standing shortfall — something altered on or around then.`\n\t\t\t\t\t: ` for ${incident.days} days, then recovered.`),\n\t\t\t)\n\t\t}\n\n\t\t// The total is stated whether or not a worst day can be named. It used to be discarded when\n\t\t// `worst` was null — which the volume floor made reachable, since a single big spike raises\n\t\t// the bar for every other day — so the tool's headline figure disappeared because the\n\t\t// SUPERLATIVE could not be computed. The floor was there to fix which day gets named, not\n\t\t// whether the total gets said.\n\t\t//\n\t\t// The unit, too: this built every sentence with formatCount, so a revenue shortfall read\n\t\t// \"Sanity missed 1,234 revenue\".\n\t\tconst total = `${row.shortfall.source} missed ${formatValue(Math.round(gap), row.unit, currency)} `\n\t\t\t+ `${row.label.toLowerCase()} over this period`\n\t\t// Only a positive ratio is a gap. `worst` takes the maximum among floor-clearing days, and\n\t\t// when every one of those was over-counted that maximum is negative — \"the gap was widest at\n\t\t// −24%\", which is not a gap.\n\t\tsentences.push(worst && worst.ratio > 0\n\t\t\t? `${total}; the gap was widest on ${tickLabel(new Date(`${worst.date}T00:00:00Z`))} at ${formatPercent(worst.ratio, 0)}.`\n\t\t\t: `${total}.`)\n\t\treturn sentences\n\t})\n\n\tconst summary = `${dates.length} days from ${tickLabel(new Date(`${dates[0]}T00:00:00Z`))} to `\n\t\t+ `${tickLabel(new Date(`${dates[dates.length - 1]}T00:00:00Z`))}. `\n\t\t+ series.map(shapeOf).join('. ') + '. ' + missed.join(' ')\n\t\t+ (markers.length > 0 ? ` ${markers.length} campaign send${markers.length === 1 ? '' : 's'} marked.` : '')\n\n\treturn (\n\t\t<Stack space={3}>\n\t\t\t{/*\n\t\t\t * ABOVE the chart, at reading size, in a card.\n\t\t\t *\n\t\t\t * These are the tool's most actionable output — how much a source missed, and the date\n\t\t\t * its coverage fell — and they were rendered `size={0} muted` inside the legend row,\n\t\t\t * between the series key and the drag instructions. The smallest, faintest type on the\n\t\t\t * page, styled as chart furniture, below the thing it explains. Three separate readers\n\t\t\t * independently reported it as the best sentence in the product and the hardest to find.\n\t\t\t */}\n\t\t\t{missed.length > 0 && (\n\t\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t{missed.map((sentence) => <Text key={sentence} size={1}>{sentence}</Text>)}\n\t\t\t\t\t</Stack>\n\t\t\t\t</Card>\n\t\t\t)}\n\n\t\t\t<div style={frameStyle}>\n\t\t\t\t<svg\n\t\t\t\t\tref={attachFrame}\n\t\t\t\t\tviewBox={`0 0 ${measured} ${height}`}\n\t\t\t\t\t// The viewBox tracks the measured width, so the scale is exactly 1 and every size\n\t\t\t\t\t// here is a real CSS pixel. Height is fixed rather than derived from the aspect\n\t\t\t\t\t// ratio, so rows keep their height whatever the pane does.\n\t\t\t\t\t// onPointerMove, not onMouseMove. The anchor and the commit were already pointer\n\t\t\t\t\t// events but the EXTENSION was not, and touch and pen send no compatibility\n\t\t\t\t\t// mousemove stream during a drag — so `brushed` stayed at [i, i], commit saw a\n\t\t\t\t\t// zero-length span, and the gesture did nothing while the legend advertised it.\n\t\t\t\t\tonPointerMove={onMove}\n\t\t\t\t\t// A selection in flight is abandoned when the pointer leaves, but nothing is left\n\t\t\t\t\t// drawn: a selection still on screen that never applied is a silent no-op that\n\t\t\t\t\t// looks like success.\n\t\t\t\t\t// tooShort is NOT cleared here. The message renders below the chart, so a reader who\n\t\t\t\t\t// mis-drags and then moves down to read the explanation crosses the boundary and\n\t\t\t\t\t// used to destroy it on the way. It clears on the next drag instead.\n\t\t\t\t\tonPointerLeave={() => { setHoverIndex(null); setBrushAnchor(null); setBrushed(null) }}\n\t\t\t\t\tonPointerDown={(event) => {\n\t\t\t\t\t\tif (!onBrush) return\n\t\t\t\t\t\tsetTooShort(false)\n\t\t\t\t\t\tconst index = indexAt(event.clientX)\n\t\t\t\t\t\tif (index === null) return\n\t\t\t\t\t\tevent.preventDefault()\n\t\t\t\t\t\t// Captured, so a drag that ends outside the chart still commits. \"From the\n\t\t\t\t\t\t// campaign send to today\" is the most natural gesture here and it finishes at\n\t\t\t\t\t\t// the right edge — without capture the pointer left, the anchor was dropped,\n\t\t\t\t\t\t// and nothing happened.\n\t\t\t\t\t\tevent.currentTarget.setPointerCapture(event.pointerId)\n\t\t\t\t\t\tsetBrushAnchor(index)\n\t\t\t\t\t\tsetBrushed([index, index])\n\t\t\t\t\t}}\n\t\t\t\t\tonPointerUp={(event) => {\n\t\t\t\t\t\tif (!onBrush || brushAnchor === null) return\n\t\t\t\t\t\tif (event.currentTarget.hasPointerCapture(event.pointerId)) {\n\t\t\t\t\t\t\tevent.currentTarget.releasePointerCapture(event.pointerId)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tconst index = indexAt(event.clientX) ?? hoverIndex ?? brushAnchor\n\t\t\t\t\t\tconst from = Math.min(brushAnchor, index)\n\t\t\t\t\t\tconst to = Math.max(brushAnchor, index)\n\t\t\t\t\t\tsetBrushAnchor(null)\n\t\t\t\t\t\tsetBrushed(null)\n\t\t\t\t\t\tcommit(from, to)\n\t\t\t\t\t}}\n\t\t\t\t\tstyle={{ width: '100%', height, display: 'block', cursor: onBrush ? 'col-resize' : 'default' }}\n\t\t\t\t\ttabIndex={0}\n\t\t\t\t\trole=\"img\"\n\t\t\t\t\t// The label carries the SHAPE, not just the subject. It previously said only which\n\t\t\t\t\t// rows existed, which is the chart's title rather than its content — and since\n\t\t\t\t\t// role=\"img\" prunes every descendant, that was the entire chart for a blind\n\t\t\t\t\t// reader. Peaks, dates and the size of the gap are the facts the drawing conveys.\n\t\t\t\t\taria-label={summary}\n\t\t\t\t\tonFocus={() => setHoverIndex((current) => current ?? dates.length - 1)}\n\t\t\t\t\tonBlur={() => setHoverIndex(null)}\n\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\t// The chart this replaced had arrow-key day stepping and this one did not, so\n\t\t\t\t\t\t// the newer, more prominent chart regressed against the older one.\n\t\t\t\t\t\tlet next: number | null = null\n\t\t\t\t\t\tconst current = hoverIndex ?? dates.length - 1\n\t\t\t\t\t\tif (event.key === 'ArrowRight') next = Math.min(current + 1, dates.length - 1)\n\t\t\t\t\t\tif (event.key === 'ArrowLeft') next = Math.max(current - 1, 0)\n\t\t\t\t\t\tif (event.key === 'Home') next = 0\n\t\t\t\t\t\tif (event.key === 'End') next = dates.length - 1\n\t\t\t\t\t\tif (event.key === 'Escape') { setHoverIndex(null); setBrushed(null); return }\n\t\t\t\t\t\tif (event.key === 'Enter' && brushed && onBrush) {\n\t\t\t\t\t\t\tevent.preventDefault()\n\t\t\t\t\t\t\tcommit(brushed[0], brushed[1])\n\t\t\t\t\t\t\tsetBrushed(null)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (next === null) return\n\t\t\t\t\t\tevent.preventDefault()\n\t\t\t\t\t\tsetSteppedByKeyboard(true)\n\t\t\t\t\t\tsetHoverIndex(next)\n\t\t\t\t\t\t// Shift extends a selection, so the brush is reachable without a pointer —\n\t\t\t\t\t\t// which matters more here than usual, since this is the tool's one\n\t\t\t\t\t\t// cross-filter and the pane is often narrow.\n\t\t\t\t\t\tif (event.shiftKey && onBrush) {\n\t\t\t\t\t\t\t// A FIXED anchor, held in its own state. Re-reading it as `brushed[0]` — the\n\t\t\t\t\t\t\t// minimum — meant extending leftward moved both ends together: [88,89] became\n\t\t\t\t\t\t\t// [87,88] became [86,87], stuck at two days forever. Since focus seeds the\n\t\t\t\t\t\t\t// cursor at the last day, shift+ArrowLeft is the natural keyboard gesture, so\n\t\t\t\t\t\t\t// the only cross-filter in the tool always failed and always scolded.\n\t\t\t\t\t\t\tconst anchor = brushAnchor ?? current\n\t\t\t\t\t\t\tif (brushAnchor === null) setBrushAnchor(current)\n\t\t\t\t\t\t\tsetBrushed([Math.min(anchor, next), Math.max(anchor, next)])\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{/* A rule at every date label, behind the data.\n\t\t\t\t\t Without them the only way to answer \"which day is this peak\" was to hold a\n\t\t\t\t\t pointer on it: three labels sat 38px below the last baseline with nothing\n\t\t\t\t\t joining them to the rows, so dating a feature meant tracing an unruled column by\n\t\t\t\t\t eye. Faint enough to stay behind the marks, present enough to read against. */}\n\t\t\t\t\t{tickIndexes.map((index) => {\n\t\t\t\t\t\tconst date = dates[index]\n\t\t\t\t\t\tif (!date) return null\n\t\t\t\t\t\tconst at = x(new Date(`${date}T00:00:00Z`))\n\t\t\t\t\t\tif (!Number.isFinite(at)) return null\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<line\n\t\t\t\t\t\t\t\tkey={`grid-${date}`}\n\t\t\t\t\t\t\t\tx1={at}\n\t\t\t\t\t\t\t\tx2={at}\n\t\t\t\t\t\t\t\ty1={TOP_PAD}\n\t\t\t\t\t\t\t\t// Down to the axis type, not to the last baseline: the rules existed to let a\n\t\t\t\t\t\t\t\t// reader date a peak without hovering, and stopping 38px short of the labels\n\t\t\t\t\t\t\t\t// they connect to left exactly the gap they were meant to close.\n\t\t\t\t\t\t\t\ty2={height - AXIS_HEIGHT + 4}\n\t\t\t\t\t\t\t\tstroke={mark('chart.grid')}\n\t\t\t\t\t\t\t\tstrokeWidth={1}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\n\t\t\t\t\t{series.map((row, rowIndex) => {\n\t\t\t\t\t\t// One colour per row, resolved once. Every mark in this row — line, stems, zero\n\t\t\t\t\t\t// ticks, region, label swatch — draws in it, so the row reads as one thing.\n\t\t\t\t\t\tconst stroke = SERIES[colorFor(row.source, row.unit, row.color)]\n\t\t\t\t\t\tconst shortfallStroke = row.shortfall\n\t\t\t\t\t\t\t? SERIES[colorFor(row.shortfall.source, row.unit, row.shortfall.color)]\n\t\t\t\t\t\t\t: null\n\t\t\t\t\t\tconst top = TOP_PAD + rowIndex * rowHeight\n\t\t\t\t\t\tconst bottom = top + rowHeight - BASELINE_GUTTER\n\t\t\t\t\t\t// The row label gets its own strip above the plot rather than sharing it. The\n\t\t\t\t\t\t// label sat at `top + 12` with the scale's maximum AT `top`, so by construction\n\t\t\t\t\t\t// the tallest point of every row — the peak day, the thing the row exists to\n\t\t\t\t\t\t// show — passed under its own name, along with the axis-max figure at `top + 4`.\n\t\t\t\t\t\t// Roughly a third of each band was type over data.\n\t\t\t\t\t\tconst plotTop = top + LABEL_STRIP\n\n\t\t\t\t\t\tconst values = row.points.map((p) => p.value).filter((v): v is number => v !== null)\n\t\t\t\t\t\t// The comparison counts toward the scale. It is drawn through the same generator\n\t\t\t\t\t\t// and inside the same clip, so a previous period that peaked HIGHER than this one\n\t\t\t\t\t\t// was cut off at the plot top rather than squashed — and a reader saw a ghost that\n\t\t\t\t\t\t// ran along the ceiling and flattened, which reads as \"last period was flat\" when\n\t\t\t\t\t\t// it was in fact bigger. The ghost is the only reference level on the chart; it has\n\t\t\t\t\t\t// to be inside the domain to be one.\n\t\t\t\t\t\tconst comparisonValues = (row.comparison ?? [])\n\t\t\t\t\t\t\t.map((p) => p.value)\n\t\t\t\t\t\t\t.filter((v): v is number => v !== null)\n\t\t\t\t\t\t// Each row scales to ITSELF. That is the whole point of small multiples: no\n\t\t\t\t\t\t// shared scale means no invented correlation between rows.\n\t\t\t\t\t\tconst peak = d3Max([...values, ...comparisonValues]) ?? 0\n\t\t\t\t\t\t// The floor drops below zero when the data does. Clipping each row to its band was\n\t\t\t\t\t\t// right, but the domain still started at 0, so a refund was positioned below the\n\t\t\t\t\t\t// baseline and therefore outside the clip — it went from bleeding into the next\n\t\t\t\t\t\t// row to not being drawn at all, while `shapeOf` still counted it in the row's\n\t\t\t\t\t\t// spoken total. The clip moved that bug rather than fixing it.\n\t\t\t\t\t\tconst lowest = Math.min(0, ...values, ...comparisonValues)\n\t\t\t\t\t\tconst y = row.domain\n\t\t\t\t\t\t\t? scaleLinear().domain(row.domain).range([bottom, plotTop])\n\t\t\t\t\t\t\t: scaleLinear().domain([lowest, peak || 1]).nice().range([bottom, plotTop])\n\t\t\t\t\t\t// Where zero sits once the domain may be negative — the baseline rule and the\n\t\t\t\t\t\t// stems both hang off this rather than off the bottom of the band.\n\t\t\t\t\t\tconst zeroY = y(0)\n\n\t\t\t\t\t\tconst at = (p: SeriesPoint) => x(new Date(`${p.date}T00:00:00Z`))\n\t\t\t\t\t\tconst defined = (p: SeriesPoint) => p.value !== null\n\n\t\t\t\t\t\t// Linear, not monotone. The file's own header argues that a smooth curve through\n\t\t\t\t\t\t// seven sales draws revenue on days that had none, and switches those rows to\n\t\t\t\t\t\t// stems — then kept the curve on the count rows, where it invents values between\n\t\t\t\t\t\t// measured days just the same. On a week across a wide pane that is a chart made\n\t\t\t\t\t\t// almost entirely of interpolation.\n\t\t\t\t\t\tconst lineGen = d3Line<SeriesPoint>().defined(defined).x(at).y((p) => y(p.value as number)).curve(curveLinear)\n\n\t\t\t\t\t\t// The blind spot: the area between what happened and what the lossier source\n\t\t\t\t\t\t// saw of it. Filled, not outlined, because it is a QUANTITY — the traffic the\n\t\t\t\t\t\t// analytics missed, drawn to scale, rather than a percentage on another tab.\n\t\t\t\t\t\t// Always visible, never hover-gated: the 24 August collapse announced itself\n\t\t\t\t\t\t// as this region widening, and putting that behind an interaction would\n\t\t\t\t\t\t// switch the alarm off.\n\t\t\t\t\t\tconst shortfallPoints = row.shortfall?.points ?? []\n\t\t\t\t\t\tconst path = row.mark === 'events' ? '' : lineGen(row.points) ?? ''\n\n\t\t\t\t\t\t// The previous window, positioned by offset: its own dates are meaningless here,\n\t\t\t\t\t\t// so point i is drawn at the x of day i of THIS window. That is what makes the\n\t\t\t\t\t\t// two shapes comparable rather than merely adjacent.\n\t\t\t\t\t\tconst ghost = row.comparison && row.comparison.length > 1\n\t\t\t\t\t\t\t? lineGen(row.comparison.slice(0, dates.length).map((point, i) => ({\n\t\t\t\t\t\t\t\tdate: dates[i] ?? point.date,\n\t\t\t\t\t\t\t\tvalue: point.value,\n\t\t\t\t\t\t\t}))) ?? ''\n\t\t\t\t\t\t\t: ''\n\n\t\t\t\t\t\t// One stem per day that had one. Width tracks the day slot so a year of data\n\t\t\t\t\t\t// stays a texture rather than a picket fence, with a floor so a single order\n\t\t\t\t\t\t// in a quarter is still findable.\n\t\t\t\t\t\t// The pitch is between ENDPOINTS, so it divides by the number of gaps, not the\n\t\t\t\t\t\t// number of days. The inter-stem gutter only exists when there is room for\n\t\t\t\t\t\t// one: at 365 days in a narrow pane the pitch is under a pixel, `pitch - 1`\n\t\t\t\t\t\t// goes negative, and the 1.5 floor then drew every stem wider than its own\n\t\t\t\t\t\t// day — adjacent days merged into a solid slab that overstated each one.\n\t\t\t\t\t\tconst pitch = plotWidth / Math.max(1, dates.length - 1)\n\t\t\t\t\t\tconst stemWidth = Math.max(1, Math.min(9, pitch > 2 ? pitch - 1 : pitch))\n\t\t\t\t\t\t// Non-zero, either sign. Filtering on `> 0` dropped refunds and chargebacks from\n\t\t\t\t\t\t// the drawing while `shapeOf` still counted them in the total, so the picture\n\t\t\t\t\t\t// and its own description disagreed.\n\t\t\t\t\t\tconst stems = row.mark === 'events'\n\t\t\t\t\t\t\t? row.points.filter((p) => p.value !== null && (p.value as number) !== 0)\n\t\t\t\t\t\t\t: []\n\n\t\t\t\t\t\t// Days measured as zero, marked on the baseline. Drawing nothing for them made a\n\t\t\t\t\t\t// day with no sales identical to a day Sanity never reported — the one\n\t\t\t\t\t\t// distinction this package exists to keep (see the header of Figure.tsx). The\n\t\t\t\t\t\t// tick is at the axis, so it reads as \"measured, and it was nothing\".\n\t\t\t\t\t\t// Only where they can read as SEPARATE marks. At ninety days in a narrow pane the\n\t\t\t\t\t\t// pitch is under four pixels, so eighty-odd abutting ticks merge into a slightly\n\t\t\t\t\t\t// darker baseline — a thicker axis, not a row of measurements — and now that the\n\t\t\t\t\t\t// series is a complete calendar that is the normal case rather than the rare one.\n\t\t\t\t\t\t// Below the threshold the row being drawn at all is what says it was measured.\n\t\t\t\t\t\tconst zeroes = row.mark === 'events' && pitch >= 6\n\t\t\t\t\t\t\t? row.points.filter((p) => p.value === 0)\n\t\t\t\t\t\t\t: []\n\t\t\t\t\t\t// The lossier source's own line, drawn always — it is named in the legend below.\n\t\t\t\t\t\t// The SAME generator, nulls left in, so `.defined()` breaks the line where GA4\n\t\t\t\t\t\t// reported nothing. Filtering them out first joined the surviving points into a\n\t\t\t\t\t\t// continuous stroke — so a GA4 outage, the single event this chart was built to\n\t\t\t\t\t\t// expose, was drawn as a smooth line straight through the missing days. The\n\t\t\t\t\t\t// shaded region beneath already broke correctly, so the fill and the line\n\t\t\t\t\t\t// disagreed about the same days and the one on top was the one that lied.\n\t\t\t\t\t\t// ALWAYS, not only when revealed.\n\t\t\t\t\t\t//\n\t\t\t\t\t\t// This was gated on the hover/pin state, so the region's own boundary existed\n\t\t\t\t\t\t// only while someone was pointing at the chart — and at rest the region was a\n\t\t\t\t\t\t// 1.40:1 wash with no perceivable extent at all. MARKS permits a fill under the\n\t\t\t\t\t\t// contrast floor only because it names a boundary that clears it; a boundary\n\t\t\t\t\t\t// behind an interaction does not satisfy that, it just hides the shortfall.\n\t\t\t\t\t\tconst shortfallLine = row.shortfall ? lineGen(shortfallPoints) ?? '' : ''\n\n\t\t\t\t\t\t// Every mark is clipped to its own band.\n\t\t\t\t\t\t//\n\t\t\t\t\t\t// scaleLinear does not clamp, so two states the chart was recently taught to\n\t\t\t\t\t\t// draw escape their row: GA4 counting MORE than the complete source puts the\n\t\t\t\t\t\t// fill above the plot top and through the label strip into the row above, and a\n\t\t\t\t\t\t// refund puts a stem below the baseline and through the gutter into the row\n\t\t\t\t\t\t// below, where either reads as that row's data.\n\t\t\t\t\t\t// Namespaced per mounted chart. `row-clip-${rowIndex}` is document-global, so two\n\t\t\t\t\t\t// instances — a split pane, or this panel beside anything reusing the scheme —\n\t\t\t\t\t\t// both define `row-clip-0`, and every colliding row resolves to whichever was\n\t\t\t\t\t\t// parsed first, whose plot width came from a differently sized pane. Silent\n\t\t\t\t\t\t// mis-clipping, no error.\n\t\t\t\t\t\tconst clipId = `${instanceId}-row-${rowIndex}`\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<g key={row.key}>\n\t\t\t\t\t\t\t\t<defs>\n\t\t\t\t\t\t\t\t\t<clipPath id={clipId}>\n\t\t\t\t\t\t\t\t\t\t<rect x={GUTTER} y={plotTop} width={plotWidth} height={Math.max(1, bottom - plotTop)} />\n\t\t\t\t\t\t\t\t\t</clipPath>\n\t\t\t\t\t\t\t\t</defs>\n\t\t\t\t\t\t\t\t<line x1={GUTTER} x2={GUTTER + plotWidth} y1={zeroY} y2={zeroY} stroke=\"currentColor\" strokeWidth={1} opacity={0.45} />\n\n\t\t\t\t\t\t\t\t{/* The row names itself, in the dead strip above its own band. Small\n\t\t\t\t\t\t\t\t multiples without in-place labels forfeit the thing they are for:\n\t\t\t\t\t\t\t\t scanning down the stack and knowing what each band is. The label\n\t\t\t\t\t\t\t\t used to live only in a wrapping legend whose order was not tied to\n\t\t\t\t\t\t\t\t vertical position. */}\n\t\t\t\t\t\t\t\t{/* Inside the band, not in the gutter above it. At `top - 6` the label's\n\t\t\t\t\t\t\t\t ascender sat four units below the PREVIOUS row's baseline and six\n\t\t\t\t\t\t\t\t above its own — bound by proximity to the wrong band, which is the\n\t\t\t\t\t\t\t\t one thing small multiples exist to get right. On row 0 it also ran\n\t\t\t\t\t\t\t\t straight through the campaign marker dots. */}\n\t\t\t\t\t\t\t\t{/* A small block of the row's colour, immediately before its name. This is\n\t\t\t\t\t\t\t\t the only place the mapping from colour to series is stated at full\n\t\t\t\t\t\t\t\t size and in position, so a reader who glances at a line can name it\n\t\t\t\t\t\t\t\t without consulting a legend elsewhere on the page. */}\n\t\t\t\t\t\t\t\t{/* Marked as furniture. It is the same colour as the row's data marks, so\n\t\t\t\t\t\t\t\t anything counting marks by fill — the render tests do — would otherwise\n\t\t\t\t\t\t\t\t count the label's swatch as a day that had an order. */}\n\t\t\t\t\t\t\t\t<rect data-swatch=\"\" x={GUTTER + 4} y={top + 3} width={8} height={8} rx={2} fill={stroke} />\n\t\t\t\t\t\t\t\t<text x={GUTTER + 16} y={top + 11} fontSize={AXIS_TYPE} fill=\"currentColor\" opacity={0.85} fontWeight={500}>\n\t\t\t\t\t\t\t\t\t{row.label}\n\t\t\t\t\t\t\t\t</text>\n\t\t\t\t\t\t\t\t{/* 0.5 put 11px type at about 3.9:1 on a white card, under the 4.5:1 floor for\n\t\t\t\t\t\t text this size. The fill's alpha was measured and documented; the type's\n\t\t\t\t\t\t was not. 0.72 clears it in both themes. */}\n\t\t\t\t\t\t<text x={GUTTER + plotWidth} y={top + 11} textAnchor=\"end\" fontSize={AXIS_TYPE} fill=\"currentColor\" opacity={0.72}>\n\t\t\t\t\t\t\t\t\t{row.source}{row.complete ? '' : ' · partial'}\n\t\t\t\t\t\t\t\t</text>\n\n\t\t\t\t\t\t\t\t{/* The axis top is what the scale REACHES, read back off the domain.\n\t\t\t\t\t\t\t\t Printing the raw peak beside a .nice()d scale gave three different\n\t\t\t\t\t\t\t\t numbers: a band topping out at 2,500, a label reading 2,356, and a\n\t\t\t\t\t\t\t\t position corresponding to neither. */}\n\t\t\t\t\t\t\t\t<text x={GUTTER - 6} y={y(y.domain()[1] as number) + 4} textAnchor=\"end\" fontSize={AXIS_TYPE} fill=\"currentColor\" opacity={0.7}>\n\t\t\t\t\t\t\t\t\t{formatValue(y.domain()[1] as number, row.unit, currency)}\n\t\t\t\t\t\t\t\t</text>\n\t\t\t\t\t\t\t\t{/* Formatted, like the top of the same axis. A literal \"0\" sat under \"$800.00\"\n\t\t\t\t\t\t or \"43.0%\" — the two ends of one axis written in different units. */}\n\t\t\t\t\t\t{/* The DOMAIN's floor, not a literal zero. The fix that was applied to the top of\n\t\t\t\t\t\t this axis was never applied to its bottom, so a row containing a refund — where\n\t\t\t\t\t\t `lowest` is negative — printed \"US$0\" at the band floor, and everything above\n\t\t\t\t\t\t that rule read as positive. */}\n\t\t\t\t\t\t<text x={GUTTER - 6} y={bottom + 4} textAnchor=\"end\" fontSize={AXIS_TYPE} fill=\"currentColor\" opacity={0.7}>\n\t\t\t\t\t\t\t{formatValue(y.domain()[0] as number, row.unit, currency)}\n\t\t\t\t\t\t</text>\n\n\t\t\t\t\t\t\t\t{/* One alpha, measured, not tied to hover.\n\t\t\t\t\t\t\t\t A previous comment claimed 0.26/0.34 cleared 3:1 and it did not —\n\t\t\t\t\t\t\t\t computing the composite against the card gives 1.80:1 and 2.22:1 in\n\t\t\t\t\t\t\t\t light. 0.48 gives 3.33:1 light and 4.16:1 dark, so one number serves\n\t\t\t\t\t\t\t\t both themes. It is constant because a quantity that brightens when\n\t\t\t\t\t\t\t\t the pointer enters the chart is jitter, not information. */}\n\t\t\t\t\t\t\t\t<g clipPath={`url(#${clipId})`}>\n\t\t\t\t\t\t\t\t{/* Behind everything, and quiet enough to read as context rather than as a\n\t\t\t\t\t\t\t\t second answer. Dashed so it is distinguishable in a screenshot and to\n\t\t\t\t\t\t\t\t anyone who cannot separate two greys. */}\n\t\t\t\t\t\t\t\t{ghost && (\n\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\td={ghost}\n\t\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\t\tstroke={mark('chart.ghost')}\n\t\t\t\t\t\t\t\t\t\tstrokeWidth={1}\n\t\t\t\t\t\t\t\t\t\tstrokeDasharray=\"2 3\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t{/* NO FILLED REGION. The two lines are enough.\n\t\t\t\t\n\t\t\t\t The shaded area between them encoded the ABSOLUTE gap, and under a roughly flat\n\t\t\t\t coverage rate the absolute gap is just the traffic curve scaled down — at 20%\n\t\t\t\t coverage it is 0.8 times traffic and tracks it exactly. So every busy day drew a\n\t\t\t\t wider alarm than every quiet day with no change whatever in the instrument. The\n\t\t\t\t coverage row directly beneath this one already makes that argument, in its own\n\t\t\t\t comment, to justify its own existence; it convicts the region above it.\n\t\t\t\t\n\t\t\t\t What the region uniquely showed — the SIZE of the loss — is stated at reading size\n\t\t\t\t in the caution card above the chart and is sortable in the table below it. The\n\t\t\t\t dashed line stays: it shows the lossier source's own level, and the collapse\n\t\t\t\t detector, the missed-pageviews sentence and the spoken summary all read from it. */}\n\t\t\t\t\t\t\t\t{/* The region's BOUNDARY, drawn because the registry promises it.\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t A fill this light cannot carry 3:1 and is not supposed to — what it\n\t\t\t\t\t\t\t\t covers has to stay readable. MARKS therefore lets it sit under the\n\t\t\t\t\t\t\t\t floor only by naming a `boundary` mark that does clear it, and until\n\t\t\t\t\t\t\t\t now that boundary was declared and never drawn: a contract honoured in\n\t\t\t\t\t\t\t\t the test and not in the picture, which is the exact defect the\n\t\t\t\t\t\t\t\t registry was built to stop. This is the line that makes the region's\n\t\t\t\t\t\t\t\t extent perceivable. */}\n\t\t\t\t\t\t\t\t{shortfallLine && (\n\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\td={shortfallLine}\n\t\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\t\tstroke={mark('chart.lossy')}\n\t\t\t\t\t\t\t\t\t\tstrokeWidth={1.5}\n\t\t\t\t\t\t\t\t\t\tstrokeDasharray=\"6 4\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t)}\n\n\n\n\t\t\t\t\t\t\t\t{zeroes.map((p) => {\n\t\t\t\t\t\t\t\t\tconst cx = at(p)\n\t\t\t\t\t\t\t\t\tif (!Number.isFinite(cx)) return null\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<rect\n\t\t\t\t\t\t\t\t\t\t\tkey={`zero-${p.date}`}\n\t\t\t\t\t\t\t\t\t\t\tx={Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2))}\n\t\t\t\t\t\t\t\t\t\t\t// Above the baseline, taller than it, and darker. At 1px and 0.3 opacity\n\t\t\t\t\t\t\t\t\t\t\t// sitting ON a rule drawn at 0.45, the distinction this exists to keep\n\t\t\t\t\t\t\t\t\t\t\t// was encoded below the baseline's own visibility.\n\t\t\t\t\t\t\t\t\t\t\ty={zeroY - 3}\n\t\t\t\t\t\t\t\t\t\t\twidth={stemWidth}\n\t\t\t\t\t\t\t\t\t\t\theight={3}\n\t\t\t\t\t\t\t\t\t\t\tfill={stroke}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\n\t\t\t\t\t\t\t\t{stems.map((p) => {\n\t\t\t\t\t\t\t\t\tconst cx = at(p)\n\t\t\t\t\t\t\t\t\t// Not `top`: that is the row band's origin, four lines up in the same scope.\n\t\t\t\t\t\t\t\t\tconst headY = y(p.value as number)\n\t\t\t\t\t\t\t\t\tif (!Number.isFinite(cx) || !Number.isFinite(headY)) return null\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<rect\n\t\t\t\t\t\t\t\t\t\t\tkey={p.date}\n\t\t\t\t\t\t\t\t\t\t\t// Clamped to the plot. The first and last day sit exactly on the\n\t\t\t\t\t\t\t\t\t\t\t// gutter edges, so a centred stem hung up to half its width outside\n\t\t\t\t\t\t\t\t\t\t\t// them — the first one into the y-axis label column.\n\t\t\t\t\t\t\t\t\t\t\tx={Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2))}\n\t\t\t\t\t\t\t\t\t\t\ty={Math.min(headY, zeroY)}\n\t\t\t\t\t\t\t\t\t\t\twidth={stemWidth}\n\t\t\t\t\t\t\t\t\t\t\t// Floored at 1.5px: a day whose value rounds to nothing on this\n\t\t\t\t\t\t\t\t\t\t\t// scale still happened, and drawing it as zero height would say\n\t\t\t\t\t\t\t\t\t\t\t// it did not.\n\t\t\t\t\t\t\t\t\t\t\theight={Math.max(1.5, Math.abs(zeroY - headY))}\n\t\t\t\t\t\t\t\t\t\t\tfill={stroke}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\n\t\t\t\t\t\t\t\t{path && <path\n\t\t\t\t\t\t\t\t\td={path}\n\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\tstroke={stroke}\n\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t// Dashed only where the LINE's own source misses things. A row whose\n\t\t\t\t\t\t\t\t\t// line is complete stays solid even when it carries a blind-spot fill.\n\t\t\t\t\t\t\t\t\t//\n\t\t\t\t\t\t\t\t\t// Opaque, too. Opacity used to carry incompleteness alongside the dash,\n\t\t\t\t\t\t\t\t\t// which dropped an incomplete line to 2.6:1 on the light theme — one\n\t\t\t\t\t\t\t\t\t// encoding doing double duty and losing contrast for it.\n\t\t\t\t\t\t\t\t\tstrokeDasharray={row.complete ? undefined : '6 4'}\n\t\t\t\t\t\t\t\t/>}\n\t\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\n\t\t\t\t\t{/* Campaign sends and other dated events, drawn through every row at once — which\n\t\t\t\t\t is the only way to see whether one moved the others. */}\n\t\t\t\t\t{markers.map((marker) => {\n\t\t\t\t\t\tconst at = x(new Date(`${marker.date}T00:00:00Z`))\n\t\t\t\t\t\tif (!Number.isFinite(at)) return null\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<g key={`${marker.date}-${marker.label}`}>\n\t\t\t\t\t\t\t\t<line\n\t\t\t\t\t\t\t\t\tx1={at}\n\t\t\t\t\t\t\t\t\tx2={at}\n\t\t\t\t\t\t\t\t\ty1={TOP_PAD - 6}\n\t\t\t\t\t\t\t\t\ty2={TOP_PAD + series.length * rowHeight - BASELINE_GUTTER}\n\t\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\t\tstrokeWidth={1.25}\n\t\t\t\t\t\t\t\t\tstrokeDasharray=\"4 3\"\n\t\t\t\t\t\t\t\t\topacity={0.5}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<circle cx={at} cy={TOP_PAD - 8} r={3} fill=\"currentColor\" opacity={0.7} />\n\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\n\t\t\t\t\t{/* The selection. Drawn as two dimmed flanks rather than a tinted middle, so the\n\t\t\t\t\t chosen span keeps the card's own background and stays the most legible part of\n\t\t\t\t\t the chart — a tint over the data would fight the marks it is meant to frame. */}\n\t\t\t\t\t{/* Bounds-checked against the CURRENT dates. `brushed` holds indices, and a commit\n\t\t\t\t\t changes the range underneath it — so after narrowing, indices from the old\n\t\t\t\t\t window pointed past the end of the new one, `dates[i]` was undefined, and both\n\t\t\t\t\t rects rendered width=\"NaN\". */}\n\t\t\t\t\t{brushed && brushed[1] > brushed[0] && brushed[1] < dates.length && (\n\t\t\t\t\t\t<g aria-hidden=\"true\">\n\t\t\t\t\t\t\t{/* Scrimmed toward the CARD, not with currentColor. currentColor is near-white\n\t\t\t\t\t\t\t on a dark card, so the \"dimmed\" flanks became the brightest part of the\n\t\t\t\t\t\t\t chart and the selection the darkest — the inverse of the intent, in half\n\t\t\t\t\t\t\t the installs. */}\n\t\t\t\t\t\t\t<rect\n\t\t\t\t\t\t\t\tx={GUTTER}\n\t\t\t\t\t\t\t\ty={TOP_PAD - 8}\n\t\t\t\t\t\t\t\twidth={Math.max(0, x(new Date(`${dates[brushed[0]]}T00:00:00Z`)) - GUTTER)}\n\t\t\t\t\t\t\t\theight={series.length * rowHeight - 10}\n\t\t\t\t\t\t\t\tfill=\"var(--card-bg-color, #ffffff)\"\n\t\t\t\t\t\t\t\topacity={0.72}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<rect\n\t\t\t\t\t\t\t\tx={x(new Date(`${dates[brushed[1]]}T00:00:00Z`))}\n\t\t\t\t\t\t\t\ty={TOP_PAD - 8}\n\t\t\t\t\t\t\t\twidth={Math.max(0, GUTTER + plotWidth - x(new Date(`${dates[brushed[1]]}T00:00:00Z`)))}\n\t\t\t\t\t\t\t\theight={series.length * rowHeight - 10}\n\t\t\t\t\t\t\t\tfill=\"var(--card-bg-color, #ffffff)\"\n\t\t\t\t\t\t\t\topacity={0.72}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</g>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{hoveredDate && (\n\t\t\t\t\t\t<line\n\t\t\t\t\t\t\tx1={x(new Date(`${hoveredDate}T00:00:00Z`))}\n\t\t\t\t\t\t\tx2={x(new Date(`${hoveredDate}T00:00:00Z`))}\n\t\t\t\t\t\t\ty1={TOP_PAD - 6}\n\t\t\t\t\t\t\ty2={TOP_PAD + series.length * rowHeight - 18}\n\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\tstrokeWidth={1}\n\t\t\t\t\t\t\topacity={0.8}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* The shared date axis, once, at the bottom.\n\t\t\t\t\t As many ticks as the measured width affords — it was fixed at three regardless,\n\t\t\t\t\t so a 1400px pane got the same three labels as a 400px one, and the one thing\n\t\t\t\t\t measuring the chart makes cheap went unused. ~90px per label keeps them from\n\t\t\t\t\t touching at the longest label this formatter produces. */}\n\t\t\t\t\t{tickIndexes.map((index, position) => {\n\t\t\t\t\t\tconst date = dates[index]\n\t\t\t\t\t\tif (!date) return null\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<text\n\t\t\t\t\t\t\t\tkey={date}\n\t\t\t\t\t\t\t\tx={x(new Date(`${date}T00:00:00Z`))}\n\t\t\t\t\t\t\t\ty={height - 6}\n\t\t\t\t\t\t\t\ttextAnchor={position === 0 ? 'start' : position === tickIndexes.length - 1 ? 'end' : 'middle'}\n\t\t\t\t\t\t\t\tfontSize={AXIS_TYPE}\n\t\t\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\t\t\topacity={0.72}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{tickLabel(new Date(`${date}T00:00:00Z`))}\n\t\t\t\t\t\t\t</text>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</svg>\n\n\t\t\t\t{/* Anchored to the crosshair the svg already draws. Rendered outside the svg so it is\n\t\t\t\t ordinary DOM — an SVG <foreignObject> is the alternative and is clipped by the\n\t\t\t\t viewBox, which is exactly the edge case the flip exists to handle. */}\n\t\t\t\t{hoveredDate && (\n\t\t\t\t\t<HoverCard\n\t\t\t\t\t\tx={x(new Date(`${hoveredDate}T00:00:00Z`))}\n\t\t\t\t\t\tpaneWidth={measured}\n\t\t\t\t\t\tdate={tickLabel(new Date(`${hoveredDate}T00:00:00Z`))}\n\t\t\t\t\t\trows={series.map((row) => {\n\t\t\t\t\t\t\tconst point = row.points.find((p) => p.date === hoveredDate)\n\t\t\t\t\t\t\tconst seen = row.shortfall?.points.find((p) => p.date === hoveredDate)\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\tkey: row.key,\n\t\t\t\t\t\t\t\tlabel: row.label,\n\t\t\t\t\t\t\t\tcolor: SERIES[colorFor(row.source, row.unit, row.color)],\n\t\t\t\t\t\t\t\t// A dash, never a zero: a day this source did not measure is not a day it\n\t\t\t\t\t\t\t\t// measured nothing, and the whole package turns on that distinction.\n\t\t\t\t\t\t\t\tvalue: point && point.value !== null ? formatValue(point.value, row.unit, currency) : '—',\n\t\t\t\t\t\t\t\tseen: seen && seen.value !== null\n\t\t\t\t\t\t\t\t\t? `${row.shortfall?.source} ${formatValue(seen.value, row.unit, currency)}`\n\t\t\t\t\t\t\t\t\t: null,\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t})}\n\t\t\t\t\t\tevents={markers.filter((m) => m.date === hoveredDate).map((m) => m.label)}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/* The readout. Every series at the hovered date, so the co-movement question is answered\n\t\t\t in numbers as well as in shape. */}\n\t\t\t<div style={readoutRow} aria-live={steppedByKeyboard ? 'polite' : 'off'}>\n\t\t\t\t<Text size={0} weight=\"medium\">\n\t\t\t\t\t{/* The selection is confirmed where the gesture happens. The only confirmation was\n\t\t\t\t\t a line above the tab strip, the full height of the panel away from the drag. */}\n\t\t\t\t\t{brushed && brushed[1] > brushed[0] && brushed[1] < dates.length\n\t\t\t\t\t\t? `${tickLabel(new Date(`${dates[brushed[0]]}T00:00:00Z`))} – ${tickLabel(new Date(`${dates[brushed[1]]}T00:00:00Z`))} · ${brushed[1] - brushed[0] + 1} days`\n\t\t\t\t\t\t: hoveredDate\n\t\t\t\t\t\t\t? tickLabel(new Date(`${hoveredDate}T00:00:00Z`))\n\t\t\t\t\t\t\t// Nothing, rather than an instruction. This is the readout slot: it exists to\n\t\t\t\t\t\t\t// hold the hovered day, and filling it with \"Hover the chart to read a day\"\n\t\t\t\t\t\t\t// meant the place where data appears permanently showed a sentence instead —\n\t\t\t\t\t\t\t// one that is also false on a touch screen. The row keeps its reserved height,\n\t\t\t\t\t\t\t// so the page still does not move when a day is picked up.\n\t\t\t\t\t\t\t: ''}\n\t\t\t\t</Text>\n\t\t\t\t{/* The same figures the floating card shows, kept here for the keyboard path and for\n\t\t\t\t assistive technology: the card is positioned from a pointer coordinate, and a\n\t\t\t\t reader stepping the chart with arrow keys needs the values announced in the live\n\t\t\t\t region rather than drawn next to a crosshair they cannot see. Visually redundant\n\t\t\t\t with the card by design — this is the accessible copy, not a second display. */}\n\t\t\t\t{hoveredDate && series.map((row) => {\n\t\t\t\t\tconst point = row.points.find((p) => p.date === hoveredDate)\n\t\t\t\t\tconst seen = row.shortfall?.points.find((p) => p.date === hoveredDate)\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<Text key={row.key} size={0} muted>\n\t\t\t\t\t\t\t{row.label}:{' '}\n\t\t\t\t\t\t\t{point && point.value !== null ? formatValue(point.value, row.unit, currency) : '—'}\n\t\t\t\t\t\t\t{seen && seen.value !== null && (\n\t\t\t\t\t\t\t\t<> · {row.shortfall?.source} saw {formatValue(seen.value, row.unit, currency)}</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t\t{hoveredDate && markers.filter((m) => m.date === hoveredDate).map((m) => (\n\t\t\t\t\t<Text key={m.label} size={0}>· {m.label}</Text>\n\t\t\t\t))}\n\t\t\t</div>\n\n\t\t\t{/* ONE legend, and every entry drawn in the colour and shape of the mark it names.\n\t\t\t\n\t\t\t There used to be three. Each row inside the plot carried a coloured swatch beside its\n\t\t\t name; the hover card carried a second set, also coloured; and this row underneath drew\n\t\t\t ─── ╌╌╌ ▮▮▮ as TEXT GLYPHS inside a muted <Text>, so every swatch here came out grey\n\t\t\t while the chart drew those same series blue, amber and green. A reader reported it as\n\t\t\t \"the legends don't match the graphs\", which is exactly what it was.\n\t\t\t\n\t\t\t The per-series rows are gone rather than recoloured: the in-plot legend already names\n\t\t\t every row, at full size, in colour, beside the line it belongs to. What is left here is\n\t\t\t only the marks that legend CANNOT carry — the ones drawn across rows or behind them,\n\t\t\t which had no legend entry anywhere. */}\n\t\t\t<div style={legendRow}>\n\t\t\t\t{series.some((row) => row.shortfall) && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{/* The label existed on the Series type all along and was rendered nowhere, so\n\t\t\t\t\t\t the dashed orange line along the top of the shaded area was an unexplained\n\t\t\t\t\t\t mark that read as a second, disagreeing traffic measurement. */}\n\t\t\t\t\t\t<LegendKey\n\t\t\t\t\t\t\tswatch={<span style={legendDash(shortfallColour(series))} />}\n\t\t\t\t\t\t\tlabel={`${shortfallLabel(series)} — the dashed line`}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t\t{series.some((row) => (row.comparison?.length ?? 0) > 1) && (\n\t\t\t\t\t<LegendKey\n\t\t\t\t\t\tswatch={<span style={legendDash(mark('chart.ghost'))} />}\n\t\t\t\t\t\tlabel=\"The same days in the window you are comparing against\"\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t\t{markers.length > 0 && (\n\t\t\t\t\t<LegendKey\n\t\t\t\t\t\tswatch={<span style={legendMarker}><span style={legendMarkerDot} /></span>}\n\t\t\t\t\t\tlabel=\"Dashed rules with a dot mark campaign sends\"\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t\t{onBrush && (\n\t\t\t\t\t// The refusal replaces the instruction rather than sitting beside it, and it is a\n\t\t\t\t\t// live region: the reader has just dragged and is looking at the chart, not at the\n\t\t\t\t\t// small print under it.\n\t\t\t\t\t<Text size={0} muted={!tooShort} aria-live=\"polite\">\n\t\t\t\t\t\t{tooShort\n\t\t\t\t\t\t\t? `That span was too short to apply — the chart needs at least ${MIN_BRUSH_DAYS} days. Drag a little wider.`\n\t\t\t\t\t\t\t: 'Drag across at least three days — or hold shift and use the arrow keys, then Enter — to narrow every panel to that span.'}\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</Stack>\n\t)\n}\n\n/**\n * One legend entry: the mark as it is actually drawn, then what it means.\n *\n * The swatch is a real styled element rather than a text glyph, which is the whole point — a glyph\n * inside a muted <Text> inherits the muted colour, so the old legend was grey while the chart was\n * not.\n */\nfunction LegendKey({ swatch, label }: { swatch: React.ReactElement; label: string }): React.ReactElement {\n\treturn (\n\t\t<span style={legendKey}>\n\t\t\t<span aria-hidden=\"true\">{swatch}</span>\n\t\t\t<Text size={0} muted>{label}</Text>\n\t\t</span>\n\t)\n}\n\n/** Swatch and label, on one baseline. */\nconst legendKey: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 6 }\n\n/**\n * A dashed line swatch, in the colour the line is drawn in.\n *\n * @param colour - the stroke\n */\nfunction legendDash(colour: string): React.CSSProperties {\n\treturn {\n\t\tdisplay: 'inline-block',\n\t\twidth: 18,\n\t\theight: 0,\n\t\tborderTop: `2px dashed ${colour}`,\n\t}\n}\n\n/**\n * A filled-region swatch: the fill, with the boundary that makes its extent visible.\n *\n * @param fill - the region colour, at the alpha it is drawn at\n * @param edge - the boundary stroke\n */\nfunction legendBlock(fill: string, edge: string): React.CSSProperties {\n\treturn {\n\t\tdisplay: 'inline-block',\n\t\twidth: 18,\n\t\theight: 10,\n\t\tbackground: fill,\n\t\tborderTop: `1.5px dashed ${edge}`,\n\t}\n}\n\n/**\n * The campaign-marker swatch: a dashed rule with the dot that sits on top of it.\n *\n * Drawn in `currentColor` at the marker's own opacity, because that is what the marker is drawn in\n * (CrossSourceTimeline's marker path, `stroke=\"currentColor\" opacity={0.5}` with a filled dot at\n * 0.7). It previously borrowed `chart.grid` — a different colour at 0.18 alpha, around 1.17:1 on a\n * white card, and with no dot at all under a label that promises one.\n */\nconst legendMarker: React.CSSProperties = {\n\tdisplay: 'inline-block',\n\tposition: 'relative',\n\twidth: 1,\n\theight: 12,\n\tborderLeft: '1px dashed currentColor',\n\t// The rule's OWN weight. The chart draws the line at 0.5 and only the dot at 0.7, so putting 0.7\n\t// on the whole element made the legend's rule heavier than the rule it names — the same class of\n\t// mismatch this legend work exists to remove.\n\topacity: 0.5,\n}\n\n/**\n * The dot that sits on top of a campaign rule.\n *\n * A real element. It was a pair of box-shadows with zero spread, and a shadow with no spread\n * replicates the whole 1x12 border box rather than a point — so the \"dot\" rendered as a 2px x 12px\n * bar, sitting left of the rule instead of on it and overflowing 6px into the row above. The mark\n * it names is a 6px circle centred on the rule, 2px above its top.\n */\nconst legendMarkerDot: React.CSSProperties = {\n\tposition: 'absolute',\n\ttop: -3,\n\tleft: -2.5,\n\twidth: 6,\n\theight: 6,\n\tborderRadius: '50%',\n\tbackground: 'currentColor',\n\topacity: 0.7,\n}\n\n/**\n * Which series key the shortfall is drawn in, so the legend and the plot cannot disagree.\n *\n * @param series - the stack\n */\nfunction shortfallKey(series: readonly Series[]): SeriesKey {\n\tconst row = series.find((r) => r.shortfall)\n\treturn colorFor(row?.shortfall?.source ?? 'GA4', row?.unit ?? 'count', row?.shortfall?.color)\n}\n\n/**\n * The shortfall line's colour.\n *\n * @param series - the stack\n */\nfunction shortfallColour(series: readonly Series[]): string {\n\treturn SERIES[shortfallKey(series)]\n}\n\n/**\n * What the shortfall line is called.\n *\n * `Series['shortfall']['label']` is set by the panel and, until now, rendered nowhere in the\n * package — so the mark it names had no legend entry on any surface.\n *\n * @param series - the stack\n */\nfunction shortfallLabel(series: readonly Series[]): string {\n\treturn series.find((r) => r.shortfall)?.shortfall?.label ?? 'Seen by your analytics'\n}\n\n/** Chart frame. */\nconst frameStyle: React.CSSProperties = { width: '100%', overflow: 'hidden', position: 'relative' }\n\n/**\n * The hover readout: every series at one date, wrapping on a narrow pane.\n *\n * The reserved height covers the wrapped case, not the empty one. At `minHeight: 18` the row grew\n * from one line to four or five on pointer-enter and shrank back on leave, pushing the legend, the\n * caveats and the disclosure sixty pixels down and up again — the page moving while the reader\n * tracks a crosshair across it. Reserving the maximum costs a band of empty space and buys a chart\n * that holds still.\n */\nconst readoutRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: SPACE.block,\n\tflexWrap: 'wrap',\n\talignItems: 'baseline',\n\tminHeight: 54,\n}\n\n\n/** Legend, wrapping rather than overflowing. */\nconst legendRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: SPACE.block,\n\tflexWrap: 'wrap',\n\talignItems: 'center',\n}\n","/**\n * Where the latest week sits among the weeks before it.\n *\n * A percentage change is the wrong summary at a foundry's volumes. Seven orders against four is\n * \"+75%\", which is the largest type on the default tab and moves twenty-five points if one more\n * person buys. The tool's own report builders refuse far better-supported claims than that.\n *\n * A RANK does not have the problem. It makes no claim about magnitude, it is stable under the\n * one-order wobble that makes the ratio useless, and it answers the question a foundry owner\n * actually opens the panel with — was this week normal? — in a form that needs no arithmetic.\n *\n * It is computed from the exact daily series the envelope already carries, so it costs no request\n * and inherits none of GA4's loss: orders and revenue come from the foundry's own records.\n */\n\n/** One day of a quantity. `null` means the day was not measured, which is not the same as zero. */\nexport interface DailyValue {\n\tdate: string\n\tvalue: number | null\n}\n\n/** Where the most recent week landed among the weeks available to compare it with. */\nexport interface WeeklyRank {\n\t/** 1 is the best week in the window. */\n\trank: number\n\t/** How many whole weeks were compared, including this one. */\n\tof: number\n\t/** The most recent week's total. */\n\tvalue: number\n}\n\n/**\n * How many whole weeks must be available before a rank is worth stating.\n *\n * Four. \"Second of three\" is barely a ranking and reads as more certain than it is; at four the\n * statement starts to carry information about what normal looks like. A Month range yields exactly\n * four, so the default view can say something; a Quarter yields thirteen.\n */\nexport const MIN_WEEKS_TO_RANK = 4\n\n/**\n * Rank the most recent whole week against the whole weeks preceding it.\n *\n * Buckets backwards from the END of the series, so the latest week is always complete rather than\n * whatever fragment the calendar left. A week containing ANY unmeasured day is dropped rather than\n * summed — a partial week would compete as though it were a quiet one, which is the absent-versus-\n * zero error this package exists to avoid, wearing a different hat.\n *\n * Returns null when fewer than `MIN_WEEKS_TO_RANK` whole weeks survive that rule.\n *\n * @param series - daily values in ascending date order\n */\nexport function weeklyRank(series: readonly DailyValue[]): WeeklyRank | null {\n\tif (series.length < 7 * MIN_WEEKS_TO_RANK) return null\n\n\t// Backwards from the end: the most recent seven days are the week being ranked.\n\tconst weeks: Array<number | null> = []\n\tfor (let end = series.length; end - 7 >= 0; end -= 7) {\n\t\tconst slice = series.slice(end - 7, end)\n\t\t// All seven days measured, or the week does not compete.\n\t\tweeks.push(slice.some((d) => d.value === null) ? null : slice.reduce((sum, d) => sum + (d.value as number), 0))\n\t}\n\n\tconst latest = weeks[0]\n\tif (latest === null || latest === undefined) return null\n\n\tconst comparable = weeks.filter((w): w is number => w !== null)\n\tif (comparable.length < MIN_WEEKS_TO_RANK) return null\n\n\t// Ties share the better rank: two equal-best weeks are both \"1st\", which is what a reader\n\t// means by best. Counting strictly-greater weeks gives that for free.\n\tconst better = comparable.filter((w) => w > latest).length\n\n\treturn { rank: better + 1, of: comparable.length, value: latest }\n}\n\n/**\n * The rank as a reader would say it.\n *\n * Deliberately plain: no \"percentile\", no \"quartile\". \"Best of the last 13 weeks\" is a sentence a\n * foundry owner can forward to a business partner without translating it first.\n *\n * @param rank - the result of weeklyRank\n */\nexport function describeRank(rank: WeeklyRank): string {\n\tconst ordinal = (n: number): string => {\n\t\tconst tens = n % 100\n\t\tif (tens >= 11 && tens <= 13) return `${n}th`\n\t\treturn `${n}${['th', 'st', 'nd', 'rd'][n % 10] ?? 'th'}`\n\t}\n\tif (rank.rank === 1) return `best of the last ${rank.of} weeks`\n\tif (rank.rank === rank.of) return `lowest of the last ${rank.of} weeks`\n\treturn `${ordinal(rank.rank)} best of the last ${rank.of} weeks`\n}\n","/**\n * How much of reality GA4 is seeing, measured against sources that are not lossy.\n *\n * GA4 is the only source here that misses things: consent refusal, ad-blocking, a tag that stops\n * firing, a data filter switched to Active. On Darden it has been reporting roughly a fifth of the\n * pageviews Vercel counts. Every panel built on it is therefore an undercount of unknown size, and\n * the tool's answer to that has been a caveat — accurate, and useless for deciding anything.\n *\n * The other sources overlap GA4 in places where both measure the same thing, and each of those\n * overlaps yields an independent estimate of the same quantity: GA4's capture rate. That is what\n * makes them worth combining. One ratio is a curiosity; three that agree are a measurement, and\n * three that disagree localise the fault.\n *\n * The estimates are deliberately NOT averaged into a single number. They measure different\n * populations and fail in different ways, and their disagreement carries more information than\n * their mean — so the model keeps them separate, picks the most trustworthy as the point estimate,\n * and uses the spread as the interval.\n */\n\n/** Where one estimate of the capture rate came from. */\nexport type CaptureBasis = 'orders' | 'pageviews' | 'email'\n\n/** One independent estimate of GA4's capture rate. */\nexport interface CaptureEstimate {\n\tbasis: CaptureBasis\n\t/** Fraction of reality GA4 saw, by this measure. Above 1 means GA4 counted more, not less. */\n\trate: number\n\t/** What GA4 reported. */\n\tobserved: number\n\t/** What the non-lossy source reported for the same thing. */\n\tactual: number\n\t/** How much weight this estimate deserves, and why, in words a panel can show. */\n\tnote: string\n}\n\n/**\n * How far apart two estimates must be before the disagreement is worth reporting.\n *\n * A fifth. Below that, the ratios differ for ordinary reasons — a crawler Vercel counted, an order\n * placed either side of midnight — and calling it a discrepancy would cry wolf on every load.\n */\nconst DISAGREEMENT_THRESHOLD = 0.2\n\n/**\n * The minimum denominator an estimate needs to mean anything.\n *\n * At seven orders a quarter, one order either way moves the orders-based rate by fourteen points.\n * Below this the ratio is noise wearing a decimal point, so it is excluded rather than shown with\n * a caveat nobody reads.\n */\nconst MIN_DENOMINATOR = 5\n\n/**\n * Build the orders-based estimate — the most trustworthy of the three.\n *\n * It compares GA4's `purchase` count against the orders that actually exist in Sanity. Same event,\n * same population, and the denominator is exact rather than merely better. Its weakness is size:\n * these foundries do single-digit orders a week, so it needs a long enough range to escape noise.\n */\nexport function fromOrders(ga4Purchases: number, sanityOrders: number): CaptureEstimate | null {\n\tif (sanityOrders < MIN_DENOMINATOR) return null\n\n\t// The sensitivity is on the SCREEN, not only in a comment four lines above the threshold. The\n\t// note used to end \"the denominator is exact\" — true of the order count and badly misleading\n\t// about the rate, which at seven orders moves fourteen points if one order lands either side of\n\t// midnight. This is the most trusted estimate, so it is the one that grosses up every corrected\n\t// figure in the tool; the reader is entitled to know how thin it is.\n\t/*\n\t * The NUMERATOR's sensitivity, which is the fragile side.\n\t *\n\t * This printed `1 / sanityOrders` and described it as the effect of \"one more or fewer order\" —\n\t * quoting the denominator's sensitivity and stating it wrongly. For a rate of k/n, one more\n\t * ORDER moves it by rate/n, which at a 20% capture and seven orders is about three points, not\n\t * fourteen. One more GA4 PURCHASE moves it by 1/n, which is the fourteen — and that is the side\n\t * that actually wobbles, because the numerator is the lossy one. The single caveat the reader\n\t * is given about the tool's most load-bearing ratio pointed at the steady half.\n\t */\n\tconst swing = Math.round((1 / sanityOrders) * 100)\n\treturn {\n\t\tbasis: 'orders',\n\t\trate: ga4Purchases / sanityOrders,\n\t\tobserved: ga4Purchases,\n\t\tactual: sanityOrders,\n\t\tnote: `GA4 purchases against the ${sanityOrders} orders that exist — the same event on both sides. `\n\t\t\t+ `Small numbers move it a long way: one more or fewer purchase seen by GA4 shifts this rate by about ${swing} points.`,\n\t}\n}\n\n/**\n * Build the pageview-based estimate.\n *\n * An UPPER bound on the capture rate, not a lower one. This comment said the opposite for the life\n * of the package, on the strength of a claim about Vercel that is not true.\n *\n * Vercel Web Analytics is not server-side. It is the `@vercel/analytics` client script — the same\n * kind of beacon GA4 uses, on a first-party path — so it is blocked too, just by fewer lists, and\n * it does not run for crawlers at all. The old reasoning was: Vercel over-counts (crawlers), so the\n * ratio understates GA4's true capture, so treat it as a floor. Both halves are wrong. Vercel is\n * itself an undercount of reality, so the real denominator is LARGER than the one used here, and\n * GA4/Vercel therefore flatters GA4 rather than maligning it.\n *\n * Which way this matters: when this estimate says GA4 sees a fifth of the traffic, the truth is a\n * fifth or less — never more. The direction of the caveat printed beside it was reversed.\n */\nexport function fromPageviews(ga4Pageviews: number, vercelPageviews: number): CaptureEstimate | null {\n\tif (vercelPageviews < MIN_DENOMINATOR) return null\n\treturn {\n\t\tbasis: 'pageviews',\n\t\trate: ga4Pageviews / vercelPageviews,\n\t\tobserved: ga4Pageviews,\n\t\tactual: vercelPageviews,\n\t\tnote: 'GA4 pageviews against Vercel’s. Read it as a ceiling: Vercel’s own counter is a script too — blocked by fewer lists than GA4, but blocked — so real traffic is higher than both and GA4’s true share is this or less.',\n\t}\n}\n\n/**\n * Build the email-based estimate.\n *\n * A known-size cohort: Mailchimp knows exactly how many people clicked through, and GA4 should see\n * a session for each. It measures the capture rate on traffic that is tagged and arriving from a\n * single known source, so a shortfall here that the others do not show points at attribution — a\n * tagging or redirect problem — rather than at measurement generally.\n */\nexport function fromEmail(ga4Users: number, mailchimpClicks: number): CaptureEstimate | null {\n\tif (mailchimpClicks < MIN_DENOMINATOR) return null\n\treturn {\n\t\tbasis: 'email',\n\t\trate: ga4Users / mailchimpClicks,\n\t\tobserved: ga4Users,\n\t\tactual: mailchimpClicks,\n\t\tnote: 'Distinct people GA4 saw arriving from email, against the distinct subscribers Mailchimp says clicked. Both sides count people, so the comparison holds — but it measures tagging as much as capture.',\n\t}\n}\n\n/**\n * A rough interval for a single rate, from how few events it was measured on.\n *\n * Two standard errors of a binomial proportion, clamped to the unit interval. At one GA4 purchase\n * against seven orders the honest range is enormous, and printing that is the point: the figure is\n * doing real work — it grosses up every corrected number in the tool — on a handful of events.\n *\n * @param estimate - the single estimate the model has\n */\nexport function samplingInterval(estimate: CaptureEstimate): { low: number; high: number } {\n\tconst n = estimate.actual\n\tif (!Number.isFinite(n) || n <= 0) return { low: estimate.rate, high: estimate.rate }\n\t/*\n\t * The interval is built around the RATE, not around a clamped copy of it.\n\t *\n\t * `p` was clamped to 0..1 for the variance — correct, a proportion's variance is only defined\n\t * there — and then the bounds were clamped to 0..1 as well. A rate above 1 is explicitly\n\t * supported here (\"above 1 means GA4 counted more, not less\"), so a 140% capture rate came back\n\t * with a range of 91% to 100%: a point estimate outside its own interval, printed as such.\n\t *\n\t * The variance still uses the clamped proportion; only the bounds follow the rate.\n\t */\n\tconst p = Math.min(1, Math.max(0, estimate.rate))\n\tconst error = 2 * Math.sqrt(Math.max(p * (1 - p), 0.02) / n)\n\treturn { low: Math.max(0, estimate.rate - error), high: estimate.rate + error }\n}\n\n/** The combined view of GA4's capture rate. */\nexport interface CaptureModel {\n\t/** Every estimate that had a usable denominator, best first. */\n\testimates: CaptureEstimate[]\n\t/** The rate to gross up by, or null when nothing was measurable. */\n\trate: number | null\n\t/** The plausible range for that rate. Equal to `rate` when only one estimate exists. */\n\tlow: number | null\n\thigh: number | null\n\t/**\n\t * What the disagreement between estimates implies, or null when they agree or only one exists.\n\t * This is the output worth reading when it is present: it says WHERE the problem is.\n\t */\n\tdiscrepancy: string | null\n}\n\n/** Order of trust. Orders beat pageviews because they compare the same event on both sides. */\nconst TRUST: CaptureBasis[] = ['orders', 'email', 'pageviews']\n\n/**\n * Combine whatever estimates were measurable.\n *\n * @param candidates - the estimates, any of which may be null when its denominator was too small\n */\nexport function captureModel(candidates: Array<CaptureEstimate | null>): CaptureModel {\n\tconst estimates = candidates\n\t\t.filter((e): e is CaptureEstimate => e !== null)\n\t\t.sort((a, b) => TRUST.indexOf(a.basis) - TRUST.indexOf(b.basis))\n\n\tif (estimates.length === 0) {\n\t\treturn { estimates, rate: null, low: null, high: null, discrepancy: null }\n\t}\n\n\tconst rates = estimates.map((e) => e.rate)\n\t// The point estimate is the most trusted single measurement, NOT the mean. Averaging a\n\t// same-event ratio against a not-like-for-like one produces a number that is neither, and the\n\t// pageview ratio would drag it down for reasons that are definitional rather than real.\n\tconst rate = estimates[0]!.rate\n\t/*\n\t * A single estimate gets a SAMPLING interval, not a zero-width one.\n\t *\n\t * `low` and `high` were the min and max across estimates, so with one estimate they both equal\n\t * the point — and the panel rendered \"~2,499\" with the subtitle \"2,499 to 2,499\" and an\n\t * Estimated badge. A zero-width interval on the one quantity this package exists to call\n\t * uncertain. On a Week range only the pageview estimate clears its denominator, so that was the\n\t * ordinary case rather than an edge one.\n\t *\n\t * The interval here is the normal approximation to a binomial proportion — the observed count\n\t * out of the true one — which is the actual reason this number is uncertain at these volumes.\n\t * It is deliberately not exact: the point is to stop printing a false precision, not to claim a\n\t * different one.\n\t */\n\tconst spread = estimates.length > 1\n\t\t? { low: Math.min(...rates), high: Math.max(...rates) }\n\t\t: samplingInterval(estimates[0]!)\n\n\treturn { estimates, rate, low: spread.low, high: spread.high, discrepancy: describeDisagreement(estimates) }\n}\n\n/**\n * Say what a disagreement between estimates means.\n *\n * Each pairing fails in a characteristic way, and naming the pattern is the whole value of holding\n * three estimates rather than one — it turns \"something is wrong\" into \"this specific thing is\n * wrong\", which is the difference between a caveat and an instruction.\n */\nfunction describeDisagreement(estimates: CaptureEstimate[]): string | null {\n\tif (estimates.length < 2) return null\n\n\tconst by = (basis: CaptureBasis) => estimates.find((e) => e.basis === basis)\n\tconst orders = by('orders')\n\tconst pageviews = by('pageviews')\n\tconst email = by('email')\n\n\tif (orders && pageviews && Math.abs(orders.rate - pageviews.rate) > DISAGREEMENT_THRESHOLD) {\n\t\treturn orders.rate > pageviews.rate\n\t\t\t? 'GA4 is capturing purchases far better than pageviews. That points at the pageview comparison rather than at a measurement failure — Vercel counts crawlers and prefetches GA4 never sees. Conversion figures here are more trustworthy than traffic figures.'\n\t\t\t: 'GA4 is capturing pageviews but missing purchases. That is not consent or ad-blocking, which would cost both equally — look at the purchase event itself: whether it fires on a page some buyers never reach, or is blocked at checkout.'\n\t}\n\n\t// The everyday pair. At seven orders a quarter the orders estimate is below its minimum on Week\n\t// and Month, so email-against-pageviews is usually the only comparison there is — and it was the\n\t// one pairing with no diagnosis at all, returning null and telling the reader nothing while two\n\t// contradictory percentages sat side by side under \"How much GA4 is seeing\".\n\tif (email && pageviews && Math.abs(email.rate - pageviews.rate) > DISAGREEMENT_THRESHOLD) {\n\t\treturn email.rate < pageviews.rate\n\t\t\t? 'Far fewer people are arriving from your mailing list than GA4’s general capture would predict. That points at the campaign links rather than at measurement — check they carry UTM tags, and that no redirect is stripping them.'\n\t\t\t: 'Email traffic is over-represented against GA4’s general capture rate, which usually means campaign links are tagged while ordinary traffic is not — so email looks larger than it is.'\n\t}\n\n\tif (email && orders && Math.abs(email.rate - orders.rate) > DISAGREEMENT_THRESHOLD) {\n\t\treturn email.rate < orders.rate\n\t\t\t? 'Traffic from email is arriving less often than GA4’s general capture rate would predict. That is an attribution problem rather than a measurement one — check the campaign links for missing UTMs, or a redirect stripping them.'\n\t\t\t: 'Email traffic is over-represented against GA4’s general capture rate, which usually means campaign links are tagged while ordinary traffic is not, so email looks larger than it is.'\n\t}\n\n\treturn null\n}\n\n/**\n * Gross a GA4 count up to an estimated true value.\n *\n * Returns null when there is no usable rate, so the caller reports the raw count rather than\n * inventing one — an estimate with no basis is worse than an honest undercount.\n *\n * @param observed - what GA4 counted\n * @param model - the capture model for the same range\n */\nexport function grossUp(\n\tobserved: number,\n\tmodel: CaptureModel,\n\t/**\n\t * Which bases may be used. Defaults to every one, which is right only when the quantity being\n\t * grossed up is the same kind of thing the estimate measured.\n\t */\n\tadmissible: CaptureBasis[] = ['orders', 'email', 'pageviews'],\n): { value: number; low: number; high: number; basis: CaptureBasis; rate: number } | null {\n\t/*\n\t * The estimate has to measure the same kind of loss as the quantity being corrected.\n\t *\n\t * Sessions were grossed up by `model.rate`, which is the most trusted estimate — the ORDERS one\n\t * whenever it exists. That is purchase-event capture, and this file argues at length that\n\t * purchase capture and traffic capture fail independently: a checkout on a third-party domain, a\n\t * purchase event without a value, a consent banner that gates one and not the other. So a broken\n\t * purchase tag was laundered into a multiplier on the traffic figure, under a heading reading\n\t * \"Sessions, corrected for what GA4 misses\".\n\t */\n\t/*\n\t * The best USABLE estimate, not merely the first.\n\t *\n\t * `usable[0]` is the most trusted admissible basis, and email outranks pageviews — so a campaign\n\t * whose links carry no UTM produced a rate of 0, which fails the checks below, and the panel\n\t * reported \"no traffic-based capture estimate for this range\" while the pageview estimate sat two\n\t * cards above it saying twenty per cent. A partially tagged campaign was worse: it governed the\n\t * correction outright and quadrupled the corrected figure against what the site-wide comparison\n\t * said.\n\t *\n\t * Trust order still decides between estimates that can each carry the correction; it must not\n\t * hand it to one that cannot.\n\t */\n\tconst usable = model.estimates.filter((e) => (\n\t\tadmissible.includes(e.basis) && e.rate > 0 && e.rate < 1 && Number.isFinite(e.rate)\n\t))\n\tif (usable.length === 0) return null\n\tconst rate = usable[0]!.rate\n\tconst rates = usable.map((e) => e.rate)\n\t// The interval is rebuilt from the ADMISSIBLE estimates only.\n\t//\n\t// It fell through to the model's own low/high when filtering left one estimate — and those are\n\t// the min and max across EVERY estimate, including the ones just excluded. So a traffic figure\n\t// was given a lower bound derived entirely from the orders rate: exactly the purchase-tag\n\t// capture this filter exists to keep out, arriving through the back door and printed as the\n\t// bottom of the range. It also bypassed the lone-estimate sampling interval in precisely the\n\t// case the filter creates.\n\tconst bounds = usable.length > 1\n\t\t? { low: Math.min(...rates), high: Math.max(...rates) }\n\t\t: samplingInterval(usable[0]!)\n\tmodel = { ...model, rate, low: bounds.low, high: bounds.high }\n\n\tif (model.rate === null || model.rate <= 0 || model.low === null || model.high === null) return null\n\tif (model.high <= 0) return null\n\n\t// A rate at or above 1 means GA4 is not undercounting on this measure, so there is nothing to\n\t// gross up and pretending otherwise would inflate a figure that is already complete.\n\tif (model.rate >= 1) return null\n\n\t/*\n\t * The interval inverts: the LOW capture rate produces the HIGH estimate of reality.\n\t *\n\t * A low bound of zero means the upper end is unbounded, and that used to discard the whole\n\t * figure — `if (model.low <= 0) return null`. The sampling interval reaches zero whenever the\n\t * rate is at or under roughly 4/(n+4), which at a fifth capture and single-digit orders is the\n\t * archetypal case for these foundries, so the correction vanished exactly where it was wanted.\n\t * Worse, the caller then reported \"not enough overlap between sources to estimate a true\n\t * figure\", which was false: there was overlap and a usable point estimate, and only the upper\n\t * bound was unbounded.\n\t *\n\t * The guard belonged to a different failure — a missing rate — and was catching a legitimately\n\t * wide interval instead. A floored bound keeps the point and states an upper end that is\n\t * honestly enormous rather than pretending there is none.\n\t */\n\tconst floor = Math.max(model.low, 0.01)\n\tconst low = observed / Math.min(1, model.high)\n\tconst high = observed / floor\n\t// Ordered, because the two clamps can cross. `Math.min(1, high)` and `Math.max(low, 0.01)` pass\n\t// each other whenever the capture rate is under about 1% — which is not an exotic case, it is a\n\t// dead tag, the exact failure this package was built for. The panel renders these verbatim, so\n\t// it printed \"~10,000\" over \"1,502 to 500\" with an Estimated badge.\n\treturn {\n\t\tvalue: observed / model.rate,\n\t\tlow: Math.min(low, high),\n\t\thigh: Math.max(low, high),\n\t\t// The estimate this figure was ACTUALLY built from. Callers described it using the model's\n\t\t// own rate and first basis, which after filtering is a different estimate — so the caption\n\t\t// read \"seeing 14% of activity, measured against the orders that exist\" beside a number\n\t\t// derived from the pageview rate of 20%. 1000/0.14 is 7,000, not the 5,000 printed. The\n\t\t// exclusion was fixed in the arithmetic and left broken in the label.\n\t\tbasis: usable[0]!.basis,\n\t\trate,\n\t}\n}\n","/**\n * Event instrumentation history — when an event started, and when it stopped.\n *\n * GA4 cannot backfill events: data from before an event was instrumented does not exist and never\n * will. A range spanning a cutover therefore mixes two measurement regimes, and a chart drawn\n * across it reads as a change in visitor behaviour when it is really a change in what was counted.\n *\n * Outages are the same problem arriving from the other direction, and are easy to miss because the\n * event still exists in the code. Darden is the worked example: its ecommerce events fired normally\n * until a script-loading change on 2025-11-20 stopped them reaching GA4 for nine months. Every one\n * of those days returns a real, honest zero from the API — a zero that means \"not recorded\", not\n * \"no sales\". Without an outage recorded here, a yearly funnel would render that as a collapse in\n * trade rather than as a gap in measurement.\n *\n * This lives in the package rather than in each site repo so all three foundries share one model\n * and one set of semantics, and so the Studio panel and the server handler agree by construction.\n */\n\nimport type { DateRange, MetricValue } from '../types'\nimport { unavailable, partial } from '../types'\n\n/** An event that predates this work — long-running, with no start discontinuity to mark. */\nexport const PREEXISTING = 'preexisting' as const\n\n/**\n * A period during which an event stopped reaching GA4 despite still existing in the code.\n * `until: null` means it is still broken as of now.\n */\nexport interface EventOutage {\n\t/** First ISO date with no data. */\n\tstart: string\n\t/** ISO date it resumed, or null if unresolved. */\n\tuntil: string | null\n\t/** Short human-readable cause, shown in the UI next to the affected figure. */\n\treason?: string\n}\n\n/** An event with a start date and, optionally, periods where it stopped firing. */\nexport interface EventHistory {\n\t/** When it began firing: `PREEXISTING`, or an ISO `YYYY-MM-DD` deploy date. */\n\tfrom: typeof PREEXISTING | string\n\t/** Periods where it stopped. Order does not matter. */\n\toutages?: EventOutage[]\n}\n\n/**\n * When an event fired on a site.\n * `PREEXISTING` = live before this work began. `null` = planned, not yet deployed.\n * An ISO `YYYY-MM-DD` string = the deploy date. An `EventHistory` adds outages.\n */\nexport type EventCutover = typeof PREEXISTING | string | null | EventHistory\n\n/** How completely an event covers a requested range. */\nexport type Coverage =\n\t| { status: 'full' }\n\t| {\n\t\t\tstatus: 'partial'\n\t\t\treason: 'spans_cutover' | 'spans_outage'\n\t\t\t/** Present when the range starts before the event was instrumented. */\n\t\t\tcutover?: string\n\t\t\t/** Outages overlapping the range. Empty for a pure cutover overlap. */\n\t\t\toutages: EventOutage[]\n\t }\n\t| {\n\t\t\tstatus: 'none'\n\t\t\treason: 'not_instrumented' | 'before_cutover' | 'unknown_event' | 'outage'\n\t\t\tcutover?: string\n\t\t\t/** Present when the whole range fell inside an outage. */\n\t\t\toutages?: EventOutage[]\n\t }\n\n/** Normalise the shorthand forms into an EventHistory, or null when not instrumented. */\nfunction toHistory(cutover: EventCutover | undefined): EventHistory | null {\n\tif (cutover === null || cutover === undefined) return null\n\tif (typeof cutover === 'string') return { from: cutover }\n\tif (!cutover.from) return null\n\treturn cutover\n}\n\n/**\n * Whether an outage overlaps a range.\n *\n * The outage's end is EXCLUSIVE — `EventOutage.until` is documented as the date recording resumed,\n * so data exists on that day. This tested `aEnd >= bStart`, which counts the resume date as still\n * missing: an outage ending 1 July against a range starting 1 July was reported as spanning it, and\n * a range with complete data throughout printed \"Undercounted: not recorded …\" plus a notice saying\n * its figures were \"too low — not a real decline\". The `swallowed` check ten lines below already read\n * `until` exclusively, so the two halves of one function disagreed.\n *\n * @param aStart - outage start, inclusive\n * @param aEnd - date recording resumed, exclusive; null means still ongoing\n * @param bStart - range start, inclusive\n * @param bEnd - range end, inclusive\n */\nfunction overlaps(aStart: string, aEnd: string | null, bStart: string, bEnd: string): boolean {\n\t// A null end means \"ongoing\", so it extends past any range end.\n\treturn aStart <= bEnd && (aEnd === null || aEnd > bStart)\n}\n\n/**\n * Determine how well an event covers a date range.\n *\n * @param cutovers - the site's event history map\n * @param eventName - GA4 event name to look up\n * @param range - the requested range\n */\nexport function coverageForRange(\n\tcutovers: Readonly<Record<string, EventCutover>>,\n\teventName: string,\n\trange: Pick<DateRange, 'start' | 'end'>,\n): Coverage {\n\tif (!(eventName in cutovers)) return { status: 'none', reason: 'unknown_event' }\n\n\tconst history = toHistory(cutovers[eventName])\n\tif (!history) return { status: 'none', reason: 'not_instrumented' }\n\n\t// --- start date -----------------------------------------------------------\n\tlet cutover: string | undefined\n\tlet startsBeforeCutover = false\n\n\tif (history.from !== PREEXISTING) {\n\t\tconst cutoverTime = Date.parse(history.from)\n\t\t// An unparseable date is a config error; treat it as unknown rather than silently trusting it.\n\t\tif (Number.isNaN(cutoverTime)) return { status: 'none', reason: 'unknown_event' }\n\n\t\tcutover = history.from\n\t\tif (Date.parse(range.end) < cutoverTime) return { status: 'none', reason: 'before_cutover', cutover }\n\t\tstartsBeforeCutover = Date.parse(range.start) < cutoverTime\n\t}\n\n\t// --- outages --------------------------------------------------------------\n\tconst hit = (history.outages ?? []).filter((o) => overlaps(o.start, o.until, range.start, range.end))\n\n\t// A range sitting entirely inside one outage has no usable data at all.\n\tconst swallowed = hit.find((o) => o.start <= range.start && (o.until === null || o.until > range.end))\n\tif (swallowed) return { status: 'none', reason: 'outage', outages: [swallowed], cutover }\n\n\tif (hit.length > 0) return { status: 'partial', reason: 'spans_outage', outages: hit, cutover }\n\tif (startsBeforeCutover) return { status: 'partial', reason: 'spans_cutover', outages: [], cutover }\n\n\treturn { status: 'full' }\n}\n\n/**\n * Wrap a raw count in a MetricValue that reflects the event's coverage of the range.\n *\n * Call this instead of returning a bare number, so a value that only covers part of the range\n * carries that fact with it all the way to the renderer.\n *\n * @param count - the number GA4 returned, or null if the query could not run\n * @param coverage - result of coverageForRange for the same event and range\n */\nexport function applyCoverage(count: number | null, coverage: Coverage): MetricValue {\n\tif (coverage.status === 'none') {\n\t\tif (coverage.reason === 'outage') {\n\t\t\tconst outage = coverage.outages?.[0]\n\t\t\treturn unavailable('outage', outage ? describeOutage(outage) : undefined)\n\t\t}\n\t\treturn unavailable(\n\t\t\tcoverage.reason === 'before_cutover' ? 'before_cutover' : 'not_instrumented',\n\t\t\tcoverage.cutover ? `Instrumented from ${coverage.cutover}` : undefined,\n\t\t)\n\t}\n\n\tif (count === null) return unavailable('source_error')\n\n\tif (coverage.status === 'partial') {\n\t\tif (coverage.reason === 'spans_outage') {\n\t\t\tconst outage = coverage.outages[0]\n\t\t\t// coveredFrom is the outage end where known, since that is when data resumes.\n\t\t\tconst from = outage?.until ?? outage?.start ?? coverage.cutover ?? ''\n\t\t\treturn partial(count, from, `Undercounted: ${describeOutage(outage)}`)\n\t\t}\n\n\t\tconst from = coverage.cutover ?? ''\n\t\treturn partial(count, from, `Only counted from ${from}, when this event was added`)\n\t}\n\n\treturn { status: 'ok', value: count }\n}\n\n/**\n * Coverage across several events that mean the same thing, taking the best of them.\n *\n * A site mapping five tester events onto one concept has usable data if any one of them has it,\n * so the strongest coverage wins rather than the weakest.\n */\nexport function coverageForAny(\n\tcutovers: Readonly<Record<string, EventCutover>>,\n\teventNames: readonly string[],\n\trange: Pick<DateRange, 'start' | 'end'>,\n): Coverage {\n\tif (eventNames.length === 0) return { status: 'none', reason: 'unknown_event' }\n\n\tconst all = eventNames.map((name) => coverageForRange(cutovers, name, range))\n\treturn (\n\t\tall.find((c) => c.status === 'full') ??\n\t\tall.find((c) => c.status === 'partial') ??\n\t\tall[0] ?? { status: 'none', reason: 'unknown_event' }\n\t)\n}\n\n/** One-line description of an outage, for display beside an affected figure. */\nexport function describeOutage(outage: EventOutage | undefined): string {\n\tif (!outage) return 'an outage affected part of this range'\n\tconst period = outage.until ? `${outage.start} to ${outage.until}` : `${outage.start} onwards`\n\treturn outage.reason ? `not recorded ${period} (${outage.reason})` : `not recorded ${period}`\n}\n\n/**\n * Human-readable notices for every event in a range that is not fully covered.\n * Surfaced in the report envelope so the panel can show caveats without recomputing them.\n */\nexport function coverageNotices(\n\tcutovers: Readonly<Record<string, EventCutover>>,\n\teventNames: readonly string[],\n\trange: Pick<DateRange, 'start' | 'end'>,\n): string[] {\n\tconst notices: string[] = []\n\n\tfor (const name of eventNames) {\n\t\tconst coverage = coverageForRange(cutovers, name, range)\n\n\t\tif (coverage.status === 'partial' && coverage.reason === 'spans_outage') {\n\t\t\tnotices.push(`${name} was ${describeOutage(coverage.outages[0])}, so figures for this range are too low — not a real decline.`)\n\t\t} else if (coverage.status === 'partial') {\n\t\t\tnotices.push(`${name} was instrumented on ${coverage.cutover}; figures before that date are missing, not zero.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'outage') {\n\t\t\tnotices.push(`${name} was ${describeOutage(coverage.outages?.[0])}, covering this whole range. The figure is unavailable, not zero.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'not_instrumented') {\n\t\t\tnotices.push(`${name} is not instrumented on this site, so it cannot be reported for any range.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'before_cutover') {\n\t\t\tnotices.push(`${name} did not exist during this range; it was instrumented on ${coverage.cutover}.`)\n\t\t}\n\t}\n\n\treturn notices\n}\n","/**\n * Per-site adapter configuration.\n *\n * The three foundries differ in ways that cannot be abstracted away: different GA4 event coverage,\n * different order schemas, different flows that exist on one site and not another. Rather than\n * branching on a site id inside the reports — which would mean editing this package to onboard a\n * fourth foundry — each site describes itself through this config and the reports stay generic.\n */\n\nimport type { EventCutover } from './cutover'\n\n/** GA4 connection for one site. */\nexport interface Ga4Config {\n\t/** Numeric GA4 property id. NOT the `G-XXXXXXX` measurement id, which is the client-side one. */\n\tpropertyId: string\n\t/** IANA timezone the property is configured with. Every range is anchored to this. */\n\ttimezone: string\n\t/**\n\t * Hostnames belonging to this site. When set, every GA4 report is restricted to them.\n\t *\n\t * This exists because a GA4 property is not necessarily one website. Darden's property also\n\t * receives `impactsport.ca`, an unrelated business, which contributed 68 of 389 sessions in a\n\t * sample week — inside the headline session count, inside both percentages computed from it,\n\t * and inside the funnel's entry rung. Without this, the tool silently reports two businesses\n\t * added together and calls the total one site.\n\t *\n\t * Staging and legacy hosts belong here too, or are deliberately left out to exclude them.\n\t * Omit entirely to accept every hostname, which is the old behaviour.\n\t */\n\thostnames?: readonly string[]\n}\n\n/** Vercel Web Analytics connection for one site. */\nexport interface VercelConfig {\n\tprojectId: string\n\tteamId?: string\n}\n\n/** Where to count orders, and what they are called on this site. */\nexport interface OrdersConfig {\n\t/** Document type holding orders. */\n\tdocumentType: string\n\t/**\n\t * Field holding the typeface references on an order, if the site has one.\n\t * Null where orders do not resolve to typefaces in a usable way.\n\t */\n\ttypefacesField: string | null\n\t/**\n\t * GROQ filter appended to exclude non-typeface orders, e.g. merch-only orders on Darden.\n\t * Merch inflates a family's apparent purchase count if not excluded.\n\t */\n\texcludeFilter?: string\n\t/**\n\t * Field holding the order's status. Defaults to `orderStatus`.\n\t *\n\t * The status is always read and always reported as a breakdown, even when nothing is filtered\n\t * on it — an operator cannot configure `countedStatuses` without first seeing what vocabulary\n\t * their own orders actually use.\n\t */\n\tstatusField?: string\n\t/**\n\t * Statuses that count as a real sale. Orders with any other status are excluded from every\n\t * figure and reported separately as an excluded count.\n\t *\n\t * Omit to count every order regardless of status, which is the old behaviour and means test,\n\t * failed, pending and refunded orders are all counted as sales. At seven orders a quarter one\n\t * test order is a 14% error, so leaving this unset is rarely right once the vocabulary is known.\n\t */\n\tcountedStatuses?: readonly string[]\n\t/**\n\t * Field holding the order's total, e.g. `total` or `amountPaid`. A number, in major units.\n\t *\n\t * Revenue was originally excluded from this package on the grounds that it reports behaviour\n\t * and the sales portal reports sales. That was the wrong line: an order total is not a customer\n\t * field, and without it a $30 web licence and a $400 multi-seat desktop licence are the same\n\t * integer — so nothing in the tool can be ranked by what it is worth. The PII rule is unchanged\n\t * and still enforced by the projection allow-list; only the value crosses over.\n\t */\n\ttotalField?: string\n\t/**\n\t * True when `totalField` holds minor units (cents) rather than major units (dollars).\n\t *\n\t * Darden's `amountCharged` is the Stripe capture in integer minor units, deliberately, because\n\t * it is the one figure that reconciles against Stripe. Reading it as dollars would report every\n\t * revenue figure a hundred times too large — a number wrong enough to be obvious once, and\n\t * quietly wrong forever in a ratio.\n\t */\n\ttotalInMinorUnits?: boolean\n\t/** ISO 4217 code for `totalField`, e.g. `USD`. Used for formatting only. */\n\tcurrency?: string\n\t/**\n\t * Licence tier fields on each typeface entry of an order, mapped to the licence type they mean.\n\t *\n\t * Darden: `{ licenseDesktop: 'Desktop', licenseWeb: 'Web', licenseApp: 'App', licenseFluid: 'Fluid' }`.\n\t * Each names an object of `{ value, label, yearsValue, yearsLabel }`, so an order already records\n\t * both the tier bought and the term it was bought for — the two variables in the pricing\n\t * question — while the tool reported a purchase as one undifferentiated event.\n\t *\n\t * Omit on a site whose orders do not carry tiers; the licence report then reports as\n\t * unavailable rather than empty.\n\t */\n\tlicenceFields?: Record<string, string>\n}\n\n/**\n * Which of this site's events carry a given meaning.\n *\n * Sites do not converge on one name. TDF already emits five distinct type-tester events\n * (`variable_font_change`, `variable_style_change`, `style_change`, `feature_change`,\n * `opentype_feature`) and has done for a long time; inventing a `tester_engaged` for it would\n * discard that history and measure the same thing twice. Each entry is a list, and the counts are\n * summed, so a site can map several of its own events onto one concept.\n */\nexport interface EventNameMap {\n\t/** Events that count as genuine type-tester engagement — a change from the default state. */\n\ttester?: string[]\n\t/** Events fired when a visitor grants analytics consent. */\n\tconsent?: string[]\n\t/**\n\t * Events fired when a visitor submits a custom-typeface or licensing enquiry.\n\t *\n\t * A commission is worth many multiples of a licence, so an enquiry is the most valuable action\n\t * on these sites. Darden has fired `enquiry_submit` throughout and no report read it, which\n\t * meant the tool gave the $30 path six funnel rungs and the four-figure path none — and scored\n\t * the enquiry-led visitor as a drop-off.\n\t */\n\tenquiry?: string[]\n\t/** Events fired when a visitor joins the mailing list — the only audience a foundry owns. */\n\tsubscribe?: string[]\n\t/** Events fired when a visitor downloads a trial font or a specimen PDF. */\n\tassetDownload?: string[]\n}\n\n/** One site's complete description of itself. */\nexport interface SiteAnalyticsConfig {\n\t/** Stable machine id, e.g. `darden`. */\n\tsiteId: string\n\t/** Human label shown in the Studio UI. */\n\tlabel: string\n\t/** Null when GA4 is not wired up for this site — reports degrade rather than fail. */\n\tga4: Ga4Config | null\n\t/** Null when Vercel Web Analytics is unavailable, e.g. on a plan tier without API access. */\n\tvercel: VercelConfig | null\n\torders: OrdersConfig\n\t/**\n\t * Whether this site has a Mailchimp audience worth reporting on.\n\t *\n\t * A flag, not credentials: the key and list id come from the environment like every other\n\t * secret. TDF has no Mailchimp at all, so it omits this and the source reports as unconfigured\n\t * rather than erroring on every request.\n\t */\n\tmailchimp?: { enabled: true }\n\t/**\n\t * When each GA4 event began firing here. Events absent from this map report as `unknown_event`,\n\t * which is the honest answer for a flow the site does not have — distinct from `not_instrumented`,\n\t * which implies it is merely pending.\n\t */\n\teventCutovers: Record<string, EventCutover>\n\t/**\n\t * This site's own names for the events the reports look for. Omit to use the defaults.\n\t * Names listed here must also appear in `eventCutovers`, or they report as unknown events.\n\t */\n\teventNames?: EventNameMap\n\t/**\n\t * Known measurement quirks for this site, shown with every report.\n\t *\n\t * Some differences between sites are semantic and cannot be normalised away — MCKL and TDF fire\n\t * `add_to_cart` on every selection change rather than on a discrete cart action, so their cart\n\t * step counts a different act from a site that fires it once. Stating that beside the figure is\n\t * the only honest option; silently comparing them is not.\n\t */\n\tcaveats?: string[]\n\t/**\n\t * Extra referrer hosts that count as design-industry coverage for this site.\n\t *\n\t * The package ships a short default list, which is necessarily generic and goes stale. A\n\t * foundry's own press — a particular newsletter, a studio blog, a regional design publication —\n\t * cannot be in it, and the resulting figure was being read as a verdict on the design press\n\t * rather than as the coverage of a hard-coded list. Entries here are added to the defaults.\n\t */\n\tdesignIndustrySources?: readonly string[]\n\n\t/**\n\t * Studio roles allowed to read these reports, enforced server-side.\n\t *\n\t * Must match the `roles` given to the plugin, which only controls whether the tab is shown.\n\t * Omit to allow any authenticated Studio user of this project. These panels expose order-derived\n\t * conversion figures, so gating to administrators is usually right — and unlike the plugin\n\t * option, setting it here actually prevents a direct call to the API route.\n\t */\n\troles?: readonly string[]\n\t/** Origins allowed to call this site's handler cross-origin, e.g. a separately deployed Studio. */\n\tallowedStudioOrigins?: string[]\n}\n\n/** A problem found while validating a site config. */\nexport interface ConfigProblem {\n\tfield: string\n\tmessage: string\n}\n\n/**\n * Validate a site config at runtime.\n *\n * Compile-time types do not survive into a consuming site's `sanity.config.js`, which is plain\n * JavaScript — so a typo'd property id or a measurement id pasted where a numeric property id\n * belongs would otherwise surface as an empty chart rather than an error.\n *\n * @param config - the candidate config\n * @returns the problems found; an empty array means the config is usable\n */\nexport function validateSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): ConfigProblem[] {\n\tconst problems: ConfigProblem[] = []\n\n\tif (!config) {\n\t\treturn [{ field: 'config', message: 'No site config supplied' }]\n\t}\n\n\tif (!config.siteId) problems.push({ field: 'siteId', message: 'Required' })\n\tif (!config.label) problems.push({ field: 'label', message: 'Required' })\n\n\tif (config.ga4) {\n\t\tconst { propertyId, timezone } = config.ga4\n\t\tif (!propertyId) {\n\t\t\tproblems.push({ field: 'ga4.propertyId', message: 'Required when ga4 is configured' })\n\t\t} else if (propertyId.startsWith('G-')) {\n\t\t\tproblems.push({\n\t\t\t\tfield: 'ga4.propertyId',\n\t\t\t\tmessage: `Looks like a measurement id (\"${propertyId}\"). The Data API needs the numeric property id from GA4 Admin instead.`,\n\t\t\t})\n\t\t} else if (!/^\\d+$/.test(propertyId)) {\n\t\t\tproblems.push({ field: 'ga4.propertyId', message: 'Must be the numeric GA4 property id' })\n\t\t}\n\n\t\tif (!timezone) {\n\t\t\tproblems.push({ field: 'ga4.timezone', message: 'Required — ranges are anchored to it' })\n\t\t} else if (!isValidTimeZone(timezone)) {\n\t\t\tproblems.push({ field: 'ga4.timezone', message: `Not a recognised IANA timezone: ${timezone}` })\n\t\t}\n\t}\n\n\t// The GROQ field slots. Not a sandbox — the config author is the site's own code author — but\n\t// the realistic mistake is a typo or a paste that points one of these at a customer field, and\n\t// a leak through them would be silent: PII read as a status becomes \"(no status)\" and PII read\n\t// as a total becomes null, so it never surfaces in a panel to be noticed.\n\tconst FIELD_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*$/\n\tconst PII_HINTS = ['email', 'name', 'phone', 'address', 'postcode', 'postal', 'zip', 'customer', 'billing']\n\n\tconst fieldSlots: Array<[string, string | null | undefined]> = [\n\t\t['orders.statusField', config.orders?.statusField],\n\t\t['orders.totalField', config.orders?.totalField],\n\t\t['orders.typefacesField', config.orders?.typefacesField],\n\t]\n\n\tfor (const name of Object.keys(config.orders?.licenceFields ?? {})) {\n\t\tfieldSlots.push([`orders.licenceFields.${name}`, name])\n\t}\n\n\tfor (const [field, value] of fieldSlots) {\n\t\tif (!value) continue\n\t\tif (!FIELD_PATH.test(value)) {\n\t\t\tproblems.push({ field, message: `Must be a plain field path, e.g. \"orderStatus.status\": ${value}` })\n\t\t\tcontinue\n\t\t}\n\t\tconst lowered = value.toLowerCase()\n\t\tconst hint = PII_HINTS.find((word) => lowered.includes(word))\n\t\tif (hint) {\n\t\t\tproblems.push({\n\t\t\t\tfield,\n\t\t\t\tmessage: `Looks like it may point at personal data (\"${hint}\"). No query in this package may project a customer field.`,\n\t\t\t})\n\t\t}\n\t}\n\n\t// A revenue figure with no currency renders as a bare integer beside columns that are counts,\n\t// which is indistinguishable from one.\n\tif (config.orders?.totalField && !config.orders.currency) {\n\t\tproblems.push({\n\t\t\tfield: 'orders.currency',\n\t\t\tmessage: 'Required when totalField is set, or revenue renders as an unlabelled number beside counts',\n\t\t})\n\t}\n\n\t// An event named here but absent from eventCutovers reports as an unknown event and silently\n\t// contributes nothing — a cross-field constraint that is cheap to check and easy to miss.\n\tfor (const [concept, names] of Object.entries(config.eventNames ?? {})) {\n\t\tfor (const name of names ?? []) {\n\t\t\tif (config.eventCutovers && !(name in config.eventCutovers)) {\n\t\t\t\tproblems.push({\n\t\t\t\t\tfield: `eventNames.${concept}`,\n\t\t\t\t\tmessage: `\"${name}\" is not in eventCutovers, so it will report as an unknown event`,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\tfor (const host of config.ga4?.hostnames ?? []) {\n\t\t// A scheme or a path here would silently match nothing: GA4's hostName dimension is the\n\t\t// bare host. Catching it at construction beats an empty dashboard.\n\t\tif (/^https?:\\/\\//.test(host) || host.includes('/')) {\n\t\t\tproblems.push({ field: 'ga4.hostnames', message: `Must be a bare hostname, not a URL: ${host}` })\n\t\t}\n\t}\n\n\tif (config.orders?.countedStatuses && config.orders.countedStatuses.length === 0) {\n\t\tproblems.push({\n\t\t\tfield: 'orders.countedStatuses',\n\t\t\tmessage: 'Empty array would exclude every order. Omit the field to count all statuses.',\n\t\t})\n\t}\n\n\tif (config.vercel && !config.vercel.projectId) {\n\t\tproblems.push({ field: 'vercel.projectId', message: 'Required when vercel is configured' })\n\t}\n\n\tif (!config.orders?.documentType) {\n\t\tproblems.push({ field: 'orders.documentType', message: 'Required' })\n\t}\n\n\tif (!config.eventCutovers) {\n\t\tproblems.push({ field: 'eventCutovers', message: 'Required — omit events the site does not have' })\n\t}\n\n\tfor (const origin of config.allowedStudioOrigins ?? []) {\n\t\tif (!/^https?:\\/\\//.test(origin)) {\n\t\t\tproblems.push({ field: 'allowedStudioOrigins', message: `Must be a full origin including scheme: ${origin}` })\n\t\t}\n\t\tif (origin.endsWith('/')) {\n\t\t\tproblems.push({ field: 'allowedStudioOrigins', message: `Must not have a trailing slash: ${origin}` })\n\t\t}\n\t}\n\n\treturn problems\n}\n\n/** Whether a string is an IANA timezone this runtime recognises. */\nexport function isValidTimeZone(timeZone: string): boolean {\n\ttry {\n\t\tnew Intl.DateTimeFormat('en-CA', { timeZone })\n\t\treturn true\n\t} catch {\n\t\treturn false\n\t}\n}\n\n/** Throw if a config is unusable, naming every problem at once rather than one per run. */\nexport function assertValidSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): asserts config is SiteAnalyticsConfig {\n\tconst problems = validateSiteConfig(config)\n\tif (problems.length > 0) {\n\t\tconst detail = problems.map((p) => ` ${p.field}: ${p.message}`).join('\\n')\n\t\tthrow new Error(`Invalid visitor-insights site config:\\n${detail}`)\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOA,IAAAA,iBAA6B;AAC7B,mBAA4B;;;ACA5B,IAAAC,gBAAqD;AACrD,IAAAC,2BAAkF;;;ACDlF,mBAAyD;AACzD,oBAA0B;AAe1B,IAAM,gBAAgB,oBAAI,IAAqC;AAc/D,IAAM,oBAAoB,oBAAI,IAAoB;AAGlD,SAAS,UAAU,OAAe,QAAiD;AAClF,SAAO,UAAU,YAAY,SAAS,GAAG,OAAO,KAAK,KAAK,OAAO,GAAG,KAAK;AAC1E;AAYO,SAAS,eAAe,MAAc,OAAe,QAAwD;AACnH,SAAO,kBAAkB,IAAI,GAAG,IAAI,IAAI,UAAU,OAAO,MAAM,CAAC,EAAE,KAAK;AACxE;AAQO,SAAS,kBAAkB,MAAc,OAAe,QAAoD,MAAqB;AACvI,QAAM,QAAS,MAA8C;AAE7D,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,KAAK,SAAS,KAAK,SAAS,GAAG;AACpF,sBAAkB,IAAI,GAAG,IAAI,IAAI,UAAU,OAAO,MAAM,CAAC,IAAI,KAAK;AAAA,EACnE;AACD;AAGA,SAAS,SAAS,MAAc,QAAgB,OAAe,QAAyC,SAA0B;AAKjI,SAAO,CAAC,MAAM,QAAQ,OAAO,QAAQ,SAAS,IAAI,QAAQ,OAAO,IAAI,WAAW,iBAAiB,EAAE,KAAK,GAAG;AAC5G;AAgBO,SAAS,oBAAuB,SAAyB,YAAiC;AAChG,SAAO,QAAQ,WAAW,WAAW,QAAQ,SAAS,WAAW;AAClE;AAGA,IAAM,cAAc;AAmCb,SAAS,UAAa,EAAE,YAAY,QAAQ,OAAO,QAAQ,SAAS,UAAU,KAAK,GAGxF;AACD,QAAM,aAAS,yBAAU,EAAE,YAAY,YAAY,CAAC;AACpD,QAAM,CAAC,OAAO,QAAQ,QAAI,uBAAyB,EAAE,QAAQ,OAAO,CAAC;AACrE,QAAM,CAAC,OAAO,QAAQ,QAAI,uBAAS,CAAC;AAGpC,QAAM,mBAAe,qBAAO,CAAC;AAE7B,QAAM,aAAS,0BAAY,MAAM,SAAS,CAAC,MAAM,IAAI,CAAC,GAAG,CAAC,CAAC;AAE3D,8BAAU,MAAM;AACf,QAAI,CAAC,QAAS;AACd,UAAM,YAAY,aAAa,UAAU;AACzC,iBAAa,UAAU;AAEvB,UAAM,aAAa,IAAI,gBAAgB;AACvC,UAAM,MAAM,SAAS,YAAY,QAAQ,OAAO,QAAQ,OAAO;AAC/D,UAAM,SAAS,cAAc,IAAI,GAAG;AAIpC,QAAI,OAAQ,UAAS,EAAE,QAAQ,SAAS,UAAU,QAA6B,OAAO,KAAK,CAAC;AAAA,SAcvF;AACJ,eAAS,CAAC,YACT,oBAAoB,SAAS,MAAM,IAAI,EAAE,GAAG,SAAS,OAAO,KAAK,IAAI,EAAE,QAAQ,UAAU,CACzF;AAAA,IACF;AAEA,mBAAe,MAAM;AAIpB,YAAM,QAAQ,OAAO,OAAO,EAAE;AAC9B,UAAI,CAAC,OAAO;AACX,iBAAS;AAAA,UACR,QAAQ;AAAA,UACR,SAAS;AAAA,QACV,CAAC;AACD;AAAA,MACD;AAEA,UAAI;AAKH,cAAM,OAAO,OAAO,eAAe,WAAW,WAAW,QAAQ,OAAO,EAAE,IAAI;AAI9E,cAAM,QAAQ,IAAI,gBAAgB,EAAE,MAAM,CAAC;AAG3C,YAAI,WAAW,YAAY,kBAAmB,OAAM,IAAI,WAAW,OAAO;AAC1E,YAAI,UAAU,YAAY,QAAQ;AACjC,gBAAM,IAAI,SAAS,OAAO,KAAK;AAC/B,gBAAM,IAAI,OAAO,OAAO,GAAG;AAAA,QAC5B;AACA,cAAM,MAAM,GAAG,IAAI,yBAAyB,MAAM,IAAI,MAAM,SAAS,CAAC;AACtE,cAAM,WAAW,MAAM,MAAM,KAAK;AAAA,UACjC,SAAS,EAAE,eAAe,UAAU,KAAK,GAAG;AAAA,UAC5C,QAAQ,WAAW;AAAA,QACpB,CAAC;AAED,YAAI,aAAa,YAAY,UAAW;AAExC,YAAI,CAAC,SAAS,IAAI;AAKjB,cAAI,SAAS,SAAS,WAAW,MAC9B,uDACA,mBAAmB,SAAS,MAAM;AACrC,cAAI,WAAW;AACf,cAAI,SAAS,WAAW,OAAO,SAAS,WAAW,KAAK;AACvD,kBAAM,OAAQ,MAAM,SAAS,KAAK,EAAE,MAAM,MAAM,IAAI;AACpD,gBAAI,MAAM,MAAO,UAAS,KAAK;AAC/B,uBAAW,MAAM,aAAa;AAAA,UAC/B;AACA,cAAI,aAAa,YAAY,UAAW;AACxC,mBAAS,EAAE,QAAQ,SAAS,SAAS,QAAQ,SAAS,CAAC;AACvD;AAAA,QACD;AAEA,cAAM,WAAY,MAAM,SAAS,KAAK;AACtC,YAAI,aAAa,YAAY,UAAW;AAExC,sBAAc,IAAI,KAAK,QAAmC;AAC1D,0BAAkB,YAAY,OAAO,QAAQ,SAAS,IAAI;AAC1D,iBAAS,EAAE,QAAQ,SAAS,SAAS,CAAC;AAAA,MACvC,SAAS,GAAG;AACX,YAAI,WAAW,OAAO,WAAW,aAAa,YAAY,UAAW;AAKrE,iBAAS,CAAC,YACT,QAAQ,WAAW,UAChB,EAAE,GAAG,SAAS,OAAO,OAAO,mBAAoB,EAAY,QAAQ,IACpE,EAAE,QAAQ,SAAS,SAAU,EAAY,QAAQ,CACpD;AAAA,MACF;AAAA,IACD;AAEA,SAAK,IAAI;AACT,WAAO,MAAM,WAAW,MAAM;AAAA,EAU/B,GAAG,CAAC,QAAQ,YAAY,QAAQ,OAAO,QAAQ,OAAO,QAAQ,KAAK,SAAS,OAAO,OAAO,CAAC;AAE3F,SAAO,EAAE,OAAO,OAAO;AACxB;;;AC/OA,IAAM,MAAM;AAGZ,IAAM,WAAW;AASjB,IAAM,aAAa;AAUZ,SAAS,WAAW,MAAyB;AACnD,QAAM,QAAkB,CAAC;AACzB,MAAI,KAAK,OAAO,WAAW,KAAK,KAAK,GAAG,EAAG,OAAM,KAAK,OAAO,KAAK,GAAG,EAAE;AACvE,MAAI,KAAK,SAAS,WAAW,KAAK,KAAK,KAAK,EAAG,OAAM,KAAK,SAAS,KAAK,KAAK,EAAE;AAG/E,MAAI,KAAK,UAAU,UAAU;AAC5B,QAAI,KAAK,QAAQ,SAAS,KAAK,KAAK,IAAI,EAAG,OAAM,KAAK,QAAQ,KAAK,IAAI,EAAE;AACzE,QAAI,KAAK,MAAM,SAAS,KAAK,KAAK,EAAE,EAAG,OAAM,KAAK,MAAM,KAAK,EAAE,EAAE;AAAA,EAClE;AACA,SAAO,MAAM,SAAS,IAAI,GAAG,GAAG,IAAI,MAAM,KAAK,GAAG,CAAC,KAAK;AACzD;AAWO,SAAS,WAAW,MAAyB;AACnD,QAAM,MAAM,KAAK,WAAW,GAAG,IAAI,KAAK,MAAM,CAAC,IAAI;AAEnD,QAAM,OAAO,IAAI,MAAM,GAAG,EAAE,KAAK,CAAC,SAAS,KAAK,WAAW,GAAG,GAAG,GAAG,CAAC;AACrE,MAAI,CAAC,KAAM,QAAO,CAAC;AAEnB,QAAM,OAAkB,CAAC;AACzB,aAAW,QAAQ,KAAK,MAAM,IAAI,SAAS,CAAC,EAAE,MAAM,GAAG,GAAG;AACzD,UAAM,YAAY,KAAK,QAAQ,GAAG;AAClC,QAAI,YAAY,EAAG;AACnB,UAAM,OAAO,KAAK,MAAM,GAAG,SAAS;AACpC,UAAM,QAAQ,KAAK,MAAM,YAAY,CAAC;AACtC,QAAI,SAAS,SAAS,WAAW,KAAK,KAAK,EAAG,MAAK,MAAM;AAAA,aAChD,SAAS,WAAW,WAAW,KAAK,KAAK,EAAG,MAAK,QAAQ;AAAA,aACzD,SAAS,UAAU,SAAS,KAAK,KAAK,EAAG,MAAK,OAAO;AAAA,aACrD,SAAS,QAAQ,SAAS,KAAK,KAAK,EAAG,MAAK,KAAK;AAAA,EAC3D;AAIA,MAAI,KAAK,UAAU,YAAY,EAAE,KAAK,QAAQ,KAAK,KAAK;AACvD,WAAO,KAAK;AACZ,WAAO,KAAK;AACZ,WAAO,KAAK;AAAA,EACb;AAEA,SAAO;AACR;AAQO,SAAS,cAAc,MAAc,MAAyB;AACpE,QAAM,MAAM,KAAK,WAAW,GAAG,IAAI,KAAK,MAAM,CAAC,IAAI;AACnD,QAAM,SAAS,IAAI,MAAM,GAAG,EAAE,OAAO,CAAC,SAAS,KAAK,SAAS,KAAK,CAAC,KAAK,WAAW,GAAG,GAAG,GAAG,CAAC;AAC7F,QAAM,OAAO,WAAW,IAAI;AAC5B,QAAM,MAAM,OAAO,CAAC,GAAG,QAAQ,IAAI,IAAI;AACvC,SAAO,IAAI,SAAS,IAAI,IAAI,IAAI,KAAK,GAAG,CAAC,KAAK;AAC/C;;;ACtGO,SAAS,iBAAiB,MAAY,UAA0B;AAEtE,QAAM,QAAQ,IAAI,KAAK,eAAe,SAAS;AAAA,IAC9C;AAAA,IACA,MAAM;AAAA,IACN,OAAO;AAAA,IACP,KAAK;AAAA,EACN,CAAC,EAAE,cAAc,IAAI;AAErB,QAAM,MAAM,CAAC,SAAiB,MAAM,KAAK,CAAC,MAAM,EAAE,SAAS,IAAI,GAAG,SAAS;AAC3E,SAAO,GAAG,IAAI,MAAM,CAAC,IAAI,IAAI,OAAO,CAAC,IAAI,IAAI,KAAK,CAAC;AACpD;AAyEO,SAAS,UAAU,SAAiB,MAAsB;AAChE,QAAM,OAAO,KAAK,MAAM,GAAG,OAAO,YAAY;AAC9C,MAAI,OAAO,MAAM,IAAI,EAAG,OAAM,IAAI,MAAM,qBAAqB,OAAO,EAAE;AACtE,SAAO,IAAI,KAAK,OAAO,OAAO,KAAU,EAAE,YAAY,EAAE,MAAM,GAAG,EAAE;AACpE;AAGO,SAAS,YAAY,OAAe,KAAqB;AAC/D,SAAO,KAAK,OAAO,KAAK,MAAM,GAAG,GAAG,YAAY,IAAI,KAAK,MAAM,GAAG,KAAK,YAAY,KAAK,KAAU,IAAI;AACvG;AAaO,IAAM,aAA0D;AAAA,EACtE,MAAM;AAAA,EACN,OAAO;AAAA,EACP,SAAS;AAAA,EACT,MAAM;AACP;AAkBO,SAAS,aAAa,KAAkC,UAAkB,MAAY,oBAAI,KAAK,GAAc;AACnH,QAAM,MAAM,iBAAiB,KAAK,QAAQ;AAC1C,QAAM,QAAQ,UAAU,KAAK,EAAE,WAAW,GAAG,IAAI,EAAE;AACnD,SAAO,EAAE,KAAK,OAAO,KAAK,SAAS;AACpC;AAQO,SAAS,cAAc,OAAkB,QAAyB,mBAA8B;AACtG,MAAI,UAAU,yBAAyB;AAStC,WAAO;AAAA,MACN,KAAK,MAAM;AAAA,MACX,OAAO,UAAU,MAAM,OAAO,IAAI;AAAA,MAClC,KAAK,UAAU,MAAM,KAAK,IAAI;AAAA,MAC9B,UAAU,MAAM;AAAA,IACjB;AAAA,EACD;AAEA,QAAM,OAAO,YAAY,MAAM,OAAO,MAAM,GAAG;AAC/C,SAAO;AAAA,IACN,KAAK,MAAM;AAAA,IACX,OAAO,UAAU,MAAM,OAAO,CAAC,IAAI;AAAA,IACnC,KAAK,UAAU,MAAM,KAAK,CAAC,IAAI;AAAA,IAC/B,UAAU,MAAM;AAAA,EACjB;AACD;AAyFO,IAAM,mBAAmB;;;ACnQhC,IAAAC,gBAAwC;AACxC,8BAAsE;;;ACuB/D,IAAM,SAAoC;AAAA;AAAA,EAEhD,QAAQ;AAAA;AAAA,EAER,cAAc;AAAA;AAAA,EAEd,aAAa;AAAA;AAAA,EAEb,QAAQ;AAAA;AAAA,EAER,SAAS;AACV;AA8BO,SAAS,UAAU,KAAa,OAAuB;AAC7D,QAAM,IAAI,SAAS,IAAI,MAAM,GAAG,CAAC,GAAG,EAAE;AACtC,QAAM,IAAI,SAAS,IAAI,MAAM,GAAG,CAAC,GAAG,EAAE;AACtC,QAAM,IAAI,SAAS,IAAI,MAAM,GAAG,CAAC,GAAG,EAAE;AACtC,SAAO,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,KAAK;AACvC;AAoBO,IAAM,aAAa;AAyBnB,IAAM,kBAAkB,wBAAwB,UAAU;AAG1D,IAAM,sBAAsB;AAC5B,IAAM,qBAAqB;AAU3B,IAAM,mBAAmB;AAAA,2BACL,UAAU;AAAA,2CACM,mBAAmB;AAAA,0CACpB,kBAAkB;AAAA,EAC1D,KAAK;AA8DA,IAAM,QAA8B;AAAA,EAC1C,cAAc,EAAE,KAAK,UAAU,OAAO,GAAG,MAAM,QAAQ,MAAM,kCAAkC;AAAA,EAC/F,mBAAmB,EAAE,KAAK,gBAAgB,OAAO,GAAG,MAAM,QAAQ,MAAM,6BAA6B;AAAA,EACrG,kBAAkB,EAAE,KAAK,eAAe,OAAO,GAAG,MAAM,QAAQ,MAAM,kCAAkC;AAAA,EACxG,cAAc,EAAE,KAAK,UAAU,OAAO,GAAG,MAAM,QAAQ,MAAM,0BAA0B;AAAA,EACvF,kBAAkB,EAAE,KAAK,UAAU,OAAO,GAAG,MAAM,QAAQ,MAAM,mEAA+D;AAAA;AAAA;AAAA;AAAA;AAAA,EAKhI,eAAe,EAAE,KAAK,gBAAgB,OAAO,GAAG,MAAM,QAAQ,MAAM,kEAAmE;AAAA,EACvI,eAAe,EAAE,KAAK,cAAc,OAAO,GAAG,MAAM,QAAQ,MAAM,iDAAiD;AAAA,EACnH,cAAc,EAAE,KAAK,WAAW,OAAO,MAAM,MAAM,aAAa,MAAM,aAAa;AAAA,EACnF,YAAY,EAAE,KAAK,UAAU,OAAO,GAAG,MAAM,QAAQ,MAAM,8CAA8C;AAAA,EACzG,aAAa,EAAE,KAAK,WAAW,OAAO,MAAM,MAAM,aAAa,MAAM,yBAAyB;AAAA;AAAA;AAAA;AAAA;AAAA,EAK9F,YAAY,EAAE,KAAK,WAAW,OAAO,GAAG,MAAM,QAAQ,MAAM,iDAAiD;AAAA,EAC7G,YAAY,EAAE,KAAK,gBAAgB,OAAO,GAAG,MAAM,QAAQ,MAAM,0DAA0D;AAAA,EAC3H,gBAAgB,EAAE,KAAK,gBAAgB,OAAO,GAAG,MAAM,QAAQ,MAAM,gDAAgD;AAAA,EACrH,qBAAqB,EAAE,KAAK,gBAAgB,OAAO,MAAM,MAAM,QAAQ,UAAU,gBAAgB,MAAM,8EAAyE;AAAA,EAChL,iBAAiB,EAAE,KAAK,WAAW,OAAO,MAAM,MAAM,aAAa,MAAM,oDAAoD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAM7H,iBAAiB,EAAE,KAAK,UAAU,OAAO,GAAG,MAAM,QAAQ,MAAM,sDAAsD;AAAA,EACtH,mBAAmB,EAAE,KAAK,gBAAgB,OAAO,GAAG,MAAM,QAAQ,MAAM,8BAA8B;AAAA,EACtG,mBAAmB,EAAE,KAAK,UAAU,OAAO,GAAG,MAAM,QAAQ,MAAM,6BAA6B;AAAA,EAC/F,mBAAmB,EAAE,KAAK,WAAW,OAAO,GAAG,MAAM,QAAQ,MAAM,8BAA8B;AAAA,EACjG,iBAAiB,EAAE,KAAK,WAAW,OAAO,MAAM,MAAM,aAAa,MAAM,8DAA8D;AACxI;AAOO,SAAS,KAAK,MAAsB;AAC1C,QAAM,IAAI,MAAM,IAAI;AACpB,MAAI,CAAC,EAAG,OAAM,IAAI,MAAM,iBAAiB,IAAI,EAAE;AAC/C,SAAO,UAAU,QAAQ,EAAE,GAAG,GAAG,EAAE,KAAK;AACzC;AAUO,SAAS,QAAQ,KAAmD;AAC1E,MAAI,QAAQ,UAAW,QAAO;AAC9B,MAAI,QAAQ,aAAc,QAAO;AACjC,SAAO,OAAO,GAAG;AAClB;AAGO,IAAM,UAAU;;;ACjNhB,SAAS,UAAU,OAAe,KAAa,MAAc,OAA4B;AAC/F,SAAO,EAAE,QAAQ,aAAa,OAAO,KAAK,MAAM,MAAM;AACvD;AAGO,SAAS,GAAG,OAA4B;AAC9C,SAAO,EAAE,QAAQ,MAAM,MAAM;AAC9B;AAGO,SAAS,YAAY,QAA2B,QAA8B;AACpF,SAAO,EAAE,QAAQ,eAAe,QAAQ,OAAO;AAChD;AASO,SAAS,QAAQ,OAAe,aAAqB,MAA2B;AACtF,SAAO,EAAE,QAAQ,WAAW,OAAO,aAAa,KAAK;AACtD;AAMO,SAAS,YAAY,QAAgD;AAK3E,SAAO,CAAC,UAAU,OAAO,WAAW,gBAAgB,OAAO,OAAO;AACnE;AAmCO,IAAM,eAAe;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD;AAKO,SAAS,aAAa,OAAqC;AACjE,SAAO,OAAO,UAAU,YAAa,aAAmC,SAAS,KAAK;AACvF;;;AF0Ca;AAlJN,IAAM,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,IAAI,SAAS,GAAG;AAS1D,IAAM,YAAiC,EAAE,SAAS,QAAQ,KAAK,MAAM,KAAK;AAG1E,IAAM,aAAkC,EAAE,SAAS,QAAQ,KAAK,MAAM,MAAM;AAG5E,IAAM,aAAkC,EAAE,SAAS,QAAQ,KAAK,MAAM,MAAM;AAG5E,IAAM,aAAkC,EAAE,SAAS,QAAQ,KAAK,MAAM,QAAQ;AAIrF,IAAM,cAAiD;AAAA,EACtD,kBAAkB;AAAA,EAClB,gBAAgB;AAAA;AAAA;AAAA,EAGhB,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,cAAc;AAAA;AAAA;AAAA,EAGd,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA,EAKhB,gBAAgB;AACjB;AAUA,IAAM,SAAS;AAGR,SAAS,YAAY,OAAuB;AAClD,SAAO,IAAI,KAAK,aAAa,MAAM,EAAE,OAAO,KAAK,MAAM,KAAK,CAAC;AAC9D;AAGO,SAAS,cAAc,OAAe,SAAS,GAAW;AAChE,SAAO,IAAI,QAAQ,KAAK,QAAQ,MAAM,CAAC;AACxC;AAIO,SAAS,YAAY,OAAe,UAAyB,iBAAwB,GAAW;AACtG,MAAI,CAAC,SAAU,QAAO,YAAY,KAAK,MAAM,KAAK,CAAC;AACnD,MAAI;AACH,WAAO,IAAI,KAAK,aAAa,QAAQ;AAAA,MACpC,OAAO;AAAA,MACP;AAAA;AAAA;AAAA;AAAA;AAAA,MAKA,uBAAuB;AAAA,MACvB,uBAAuB;AAAA,IACxB,CAAC,EAAE,OAAO,KAAK;AAAA,EAChB,QAAQ;AAEP,WAAO,GAAG,YAAY,KAAK,MAAM,KAAK,CAAC,CAAC,IAAI,QAAQ;AAAA,EACrD;AACD;AAwCO,IAAM,iBAAiB;AAEvB,SAAS,MAAM,EAAE,SAAS,UAAU,aAAa,MAAM,OAAO,QAAQ,GAA0C;AAItH,MAAI,CAAC,OAAO,SAAS,OAAiB,KAAK,CAAC,OAAO,SAAS,QAAkB,EAAG,QAAO;AACxF,QAAM,MAAM;AACZ,QAAM,SAAS;AAEf,QAAM,WAAW,MAAM;AAQvB,QAAM,kBAAkB,SAAS,YAC9B,KAAK,IAAI,QAAQ,IAAI,OACrB,WAAW,KAAK,KAAK,IAAI,WAAW,KAAK,IAAI,MAAM,CAAC,IAAI;AAC3D,MAAI,aAAa,KAAK,iBAAiB;AAItC,WACC,6CAAC,UAAK,OAAO,WAAW,GAAG;AAAA;AAAA,MACjB,6CAAC,UAAK,OAAO,eAAe;AAAA;AAAA,QAAO,SAAS,YAAY,GAAG,OAAO,QAAQ,CAAC,CAAC,MAAM,YAAY,MAAM;AAAA,SAAE;AAAA,OAChH;AAAA,EAEF;AAIA,QAAM,SAAS,WAAW,IAAI,OAAO,WAAW,KAAK,IAAI,MAAM;AAC/D,QAAM,SAAS,WAAW;AAC1B,QAAM,QAAQ,SAAS,WAAW;AAIlC,QAAM,WAAW,SAAS,YAAY,GAAG,OAAO,QAAQ,CAAC,CAAC,MAAM,YAAY,MAAM;AAOlF,QAAM,eAAe,WAAW,QAAQ,SAAS,aAAa,KAAK,IAAI,MAAM,IAAI;AAEjF,QAAM,YAAY,WAAW,OACzB,SAAS,QAAQ,SAClB,eACA,KACA,SAAS,YAGR,GAAG,SAAS,MAAM,EAAE,GAAG,SAAS,QAAQ,CAAC,CAAC,SAC1C,GAAG,SAAS,MAAM,EAAE,GAAG,cAAc,QAAQ,CAAC,CAAC;AAEnD;AAAA;AAAA;AAAA;AAAA,IAIC,6CAAC,UAAK,OAAO,WAAW,GACvB;AAAA,kDAAC,UAAK,eAAY,QAAQ,iBAAM;AAAA,MAC/B,YAAY,IAAI,SAAS,KAAK;AAAA,MAC/B,6CAAC,UAAK,OAAO,eAAe;AAAA;AAAA,QAAO;AAAA,SAAS;AAAA,MAG3C,gBAAgB,4CAAC,UAAK,OAAO,eAAe,mCAAkB;AAAA,OAChE;AAAA;AAEF;AAMA,SAAS,aAAkC;AAc1C,SAAO;AAAA,IACN,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,YAAY;AAAA,IACZ,OAAO;AAAA,IACP,YAAY;AAAA,EACb;AACD;AAQA,IAAM,gBAAqC,EAAE,OAAO,iBAAiB,YAAY,IAAI;AAGrF,IAAM,eAAe;AAGrB,IAAM,iBAAiB;AAEhB,SAAS,UAAU;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD,GAWuB;AAOtB,QAAM,OAAO,IAAI,eAAe,iBAAiB;AACjD,SACC;AAAA,IAAC;AAAA;AAAA,MACA,OAAO;AAAA,QACN,GAAG;AAAA,QACH,MAAM,OAAO,SAAY,IAAI;AAAA,QAC7B,OAAO,OAAO,YAAY,IAAI,eAAe;AAAA,MAC9C;AAAA,MAEA;AAAA,oDAAC,SAAI,OAAO,WAAY,gBAAK;AAAA,QAC5B,KAAK,IAAI,CAAC,QACV,6CAAC,SAAkB,OAAO,UAEzB;AAAA,sDAAC,UAAK,OAAO,EAAE,GAAG,aAAa,YAAY,IAAI,MAAM,GAAG;AAAA,UACxD,4CAAC,UAAK,OAAO,YAAa,cAAI,OAAM;AAAA,UACpC,6CAAC,UAAK,OAAO,YACX;AAAA,gBAAI;AAAA,YACJ,IAAI,QAAQ,6CAAC,UAAK,OAAO,WAAW;AAAA;AAAA,cAAI,IAAI;AAAA,eAAK;AAAA,aACnD;AAAA,aAPS,IAAI,GAQd,CACA;AAAA,QACA,OAAO,IAAI,CAAC,UACZ,4CAAC,SAAgB,OAAO,YAAa,mBAA3B,KAAiC,CAC3C;AAAA;AAAA;AAAA,EACF;AAEF;AASA,IAAM,YAAiC;AAAA,EACtC,UAAU;AAAA,EACV,KAAK;AAAA;AAAA;AAAA,EAGL,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,UAAU;AAAA,EACV,SAAS;AAAA,EACT,cAAc;AAAA,EACd,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,SAAS;AAAA,EACT,eAAe;AAAA,EACf,KAAK;AACN;AAGA,IAAM,YAAiC,EAAE,UAAU,IAAI,YAAY,KAAK,SAAS,KAAK,cAAc,EAAE;AAGtG,IAAM,WAAgC,EAAE,SAAS,QAAQ,YAAY,UAAU,KAAK,GAAG,UAAU,IAAI,YAAY,IAAI;AAGrH,IAAM,cAAmC,EAAE,OAAO,GAAG,QAAQ,GAAG,cAAc,GAAG,MAAM,WAAW;AAGlG,IAAM,aAAkC,EAAE,SAAS,MAAM,YAAY,UAAU,UAAU,UAAU,cAAc,WAAW;AAG5H,IAAM,aAAkC,EAAE,YAAY,QAAQ,oBAAoB,gBAAgB,YAAY,KAAK,YAAY,SAAS;AAGxI,IAAM,YAAiC,EAAE,SAAS,KAAK,YAAY,IAAI;AAGvE,IAAM,aAAkC,EAAE,UAAU,IAAI,SAAS,KAAK,WAAW,8DAA8D,YAAY,GAAG,WAAW,EAAE;AAyC3K,SAAS,aAAa,QAA0C;AAC/D,SAAO,OAAO,cAAc,QAAQ,UAAU,OAAO,WAAW,CAAC,UAAU;AAC5E;AAMO,SAAS,aAAa,EAAE,QAAQ,OAAO,OAAO,GAAG,OAAO,SAAS,WAAW,KAAK,GAA0C;AACjI,MAAI,OAAO,WAAW,eAAe;AAGpC,UAAM,SAAS,YAAY,OAAO,MAAM,KAAK;AAC7C,UAAM,SAAS,OAAO,SAAS,GAAG,MAAM,KAAK,OAAO,MAAM,KAAK;AAE/D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAaC,4CAAC,mCAAQ,SAAS,4CAAC,+BAAI,SAAS,GAAG,sDAAC,gCAAK,MAAM,GAAI,kBAAO,GAAO,GAAQ,QAAM,MAC9E,uDAAC,gCAAK,MAAY,OAAK,MACtB;AAAA,oDAAC,UAAK,eAAY,QAAO,oBAAC;AAAA,QAC1B,6CAAC,UAAK,OAAO,gBAAiB;AAAA;AAAA,UAAM;AAAA,UAAgB;AAAA,WAAO;AAAA,SAC5D,GACD;AAAA;AAAA,EAEF;AAIA,QAAM,YAAY,SAAS,YACxB,GAAG,OAAO,MAAM,QAAQ,CAAC,CAAC,MAC1B,SAAS,UACR,YAAY,OAAO,OAAO,QAAQ,IAClC,YAAY,OAAO,KAAK;AAE5B,MAAI,OAAO,WAAW,WAAW;AAChC,WACC,6CAAC,iCAAM,OAAO,GACb;AAAA,mDAAC,gCAAK,MACJ;AAAA;AAAA,QACD,6CAAC,UAAK,OAAO,gBAAgB;AAAA;AAAA,UAAY,OAAO;AAAA,WAAK;AAAA,SACtD;AAAA,MACA,4CAAC,iCAAM,MAAK,WAAU,UAAU,GAAI,uBAAa,MAAM,GAAE;AAAA,OAC1D;AAAA,EAEF;AAEA,MAAI,OAAO,WAAW,aAAa;AAMlC,UAAM,QAAQ,SAAS,YACpB,GAAG,OAAO,IAAI,QAAQ,CAAC,CAAC,QAAQ,OAAO,KAAK,QAAQ,CAAC,CAAC,MACtD,SAAS,UACR,GAAG,YAAY,OAAO,KAAK,QAAQ,CAAC,OAAO,YAAY,OAAO,MAAM,QAAQ,CAAC,KAC7E,GAAG,YAAY,OAAO,GAAG,CAAC,OAAO,YAAY,OAAO,IAAI,CAAC;AAE7D,WACC,6CAAC,iCAAM,OAAO,GACb;AAAA,mDAAC,gCAAK,MACL;AAAA,oDAAC,UAAK,eAAY,QAAO,eAAC;AAAA,QAAQ;AAAA,QAClC,6CAAC,UAAK,OAAO,gBAAgB;AAAA;AAAA,UAAoB;AAAA,UAAM;AAAA,UAAG,OAAO;AAAA,WAAM;AAAA,SACxE;AAAA,MACA,4CAAC,gCAAK,MAAM,GAAG,OAAK,MAAE,iBAAM;AAAA,MAC5B,4CAAC,iCAAM,MAAK,WAAU,UAAU,GAAG,uBAAS;AAAA,OAC7C;AAAA,EAEF;AAEA,MAAI,OAAO,WAAW,MAAM;AAG3B,UAAM,YAAmB;AACzB,SAAK;AAAA,EACN;AAEA,SAAO,4CAAC,gCAAK,MAAa,qBAAU;AACrC;AA4BO,SAAS,UAAe,EAAE,OAAO,MAAM,SAAS,QAAQ,YAAY,MAAM,SAAS,GAAmD;AAC5I,MAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,SACC,6CAAC,aAAQ,OAAO,iBACf;AAAA,gDAAC,aAAQ,OAAO,mBAAoB,iBAAM;AAAA,IAC1C,6CAAC,SAAI,OAAO,EAAE,WAAW,GAAG,GAC1B;AAAA,cAAQ,4CAAC,gCAAK,MAAM,GAAG,OAAK,MAAC,OAAO,EAAE,cAAc,GAAG,SAAS,QAAQ,GAAI,gBAAK;AAAA,MAClF;AAAA,QAAC;AAAA;AAAA,UACA,SAAS;AAAA,UACT;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA;AAAA,MACD;AAAA,OACD;AAAA,KACD;AAEF;AAGA,IAAM,kBAAuC,EAAE,OAAO,OAAO;AAG7D,IAAM,oBAAyC;AAAA,EAC9C,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AAAA,EACT,gBAAgB;AAAA,EAChB,qBAAqB;AAAA,EACrB,OAAO;AACR;AA8BO,SAAS,gBAAgB,EAAE,MAAM,QAAQ,WAAW,GAAoD;AAC9G,MAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,QAAM,QAAQ,KAAK,OAAO,CAAC,KAAK,QAAQ,MAAM,IAAI,OAAO,CAAC;AAC1D,MAAI,SAAS,EAAG,QAAO;AAEvB,QAAM,UAAU,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;AAE1D,SACC,6CAAC,iCAAM,OAAO,GACZ;AAAA,YAAQ,IAAI,CAAC,QAAQ;AACrB,YAAM,QAAQ,IAAI,QAAQ;AAC1B,aACC,6CAAC,iCAAM,OAAO,GACb;AAAA,qDAAC,SAAI,OAAO,WACX;AAAA,uDAAC,gCAAK,MAAM,GAAG,QAAO,UACpB;AAAA,gBAAI;AAAA,YACJ,IAAI,YAAY,6CAAC,gCAAK,MAAM,GAAG,OAAK,MAAC,IAAG,QAAQ;AAAA;AAAA,cAAI;AAAA,cAAG,IAAI;AAAA,eAAS;AAAA,aACtE;AAAA,UACA,6CAAC,gCAAK,MAAM,GACV;AAAA,mBAAO,IAAI,KAAK;AAAA,YACjB,6CAAC,gCAAK,MAAM,GAAG,OAAK,MAAC,IAAG,QAAQ;AAAA;AAAA,cAAI;AAAA,cAAE,cAAc,OAAO,CAAC;AAAA,cAAE;AAAA,eAAC;AAAA,aAChE;AAAA,WACD;AAAA,QACA,4CAAC,SAAI,eAAY,QAAO,OAAO,UAC9B,sDAAC,SAAI,OAAO,EAAE,GAAG,SAAS,OAAO,GAAG,KAAK,IAAI,GAAG,QAAQ,GAAG,CAAC,IAAI,GAAG,GACpE;AAAA,WAbqB,IAAI,GAc1B;AAAA,IAEF,CAAC;AAAA,IACD,6CAAC,gCAAK,MAAM,GAAG,OAAK,MAAE;AAAA;AAAA,MAAW;AAAA,MAAG,OAAO,KAAK;AAAA,OAAE;AAAA,KACnD;AAEF;AA0DO,IAAM,uBAAuB;AAU7B,SAAS,UAAU,KAAqB;AAC9C,QAAM,OAAO,oBAAI,KAAK,GAAG,GAAG,YAAY;AACxC,MAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,EAAG,QAAO;AACzC,SAAO,GAAG,KAAK,WAAW,CAAC,IAAI,KAAK,eAAe,SAAS,EAAE,OAAO,SAAS,UAAU,MAAM,CAAC,CAAC;AACjG;AAYA,SAAS,cAAc,OAAuB;AAC7C,QAAM,QAAQ,CAAC,iBAAiB,mBAAmB,mBAAmB,iBAAiB;AACvF,SAAO,KAAK,MAAM,QAAQ,MAAM,MAAM,CAAW;AAClD;AAUA,SAAS,YAAY,QAAuE;AAC3F,QAAM,OAAO,oBAAI,IAAoB;AACrC,aAAW,SAAS,QAAQ;AAC3B,eAAW,WAAW,MAAM,YAAY,CAAC,GAAG;AAC3C,UAAI,QAAQ,QAAQ,KAAK,CAAC,KAAK,IAAI,QAAQ,GAAG,EAAG,MAAK,IAAI,QAAQ,KAAK,QAAQ,KAAK;AAAA,IACrF;AAAA,EACD;AACA,SAAO,CAAC,GAAG,IAAI,EAAE,IAAI,CAAC,CAAC,KAAK,KAAK,OAAO,EAAE,KAAK,MAAM,EAAE;AACxD;AAaA,SAAS,aAAa,OAAe,OAAuB;AAC3D,MAAI,EAAE,QAAQ,MAAM,EAAE,QAAQ,GAAI,QAAO;AACzC,SAAO,KAAK,IAAI,GAAG,QAAQ,KAAK;AACjC;AAsBA,SAAS,SACR,OACA,OACA,OACA,OACA,YAC4B;AAG5B,MAAI,UAAU,EAAG,QAAO,4CAAC,gCAAK,MAAM,GAAG,OAAK,MAAC,8CAA2B;AAQxE,MAAI,MAAM,SAAS;AAClB,WACC,6CAAC,gCAAK,MAAM,GAAG,OAAK,MAAC;AAAA;AAAA,MACJ,MAAM,QAAQ;AAAA,MAAK;AAAA,OAEpC;AAAA,EAEF;AAEA,QAAM,UAAU,SAAS,wBAAwB,MAAM,SAAS;AAChE,MAAI,CAAC,QAAS,QAAO;AAErB,SAAO,6CAAC,gCAAK,MAAM,GAAG,OAAK,MAAE;AAAA,kBAAc,OAAO,CAAC;AAAA,IAAE;AAAA,IAAK,WAAW,YAAY;AAAA,KAAE;AACpF;AAcO,SAAS,YAAY,EAAE,QAAQ,YAAY,GAAgD;AACjG,QAAM,CAAC,WAAW,YAAY,QAAI,wBAAwB,IAAI;AAC9D,QAAM,WAAO,sBAAqC,CAAC,CAAC;AAEpD,QAAM,QAAQ,OAAO,CAAC,GAAG,SAAS;AAClC,MAAI,OAAO,WAAW,EAAG,QAAO;AAEhC,QAAM,YAAY,CAAC,OAA4B,UAAkB;AAChE,QAAI,OAAsB;AAC1B,QAAI,MAAM,QAAQ,eAAe,MAAM,QAAQ,aAAc,QAAO,KAAK,IAAI,QAAQ,GAAG,OAAO,SAAS,CAAC;AACzG,QAAI,MAAM,QAAQ,aAAa,MAAM,QAAQ,YAAa,QAAO,KAAK,IAAI,QAAQ,GAAG,CAAC;AACtF,QAAI,MAAM,QAAQ,OAAQ,QAAO;AACjC,QAAI,MAAM,QAAQ,MAAO,QAAO,OAAO,SAAS;AAChD,QAAI,SAAS,KAAM;AACnB,UAAM,eAAe;AACrB,SAAK,QAAQ,IAAI,GAAG,MAAM;AAAA,EAC3B;AAKA,QAAM,cAAc,OAAO;AAAA,IAAK,CAAC,OAAO,UACvC,QAAQ,KAAK,CAAC,MAAM,WAAW,EAAE,SAAS,wBAAwB,MAAM,SAAS;AAAA,EAClF;AAEA,SACC,4EACA;AAAA,gDAAC,QAAG,OAAO,YACT,iBAAO,IAAI,CAAC,OAAO,UAAU;AAC7B,YAAM,WAAW,QAAQ,IAAI,OAAO,QAAQ,CAAC,IAAI;AAIjD,YAAM,QAAQ,QAAQ,IAAI,MAAM,QAAQ,QAAQ;AAChD,YAAM,QAAQ,KAAK,IAAI,KAAK,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG;AACpD,YAAM,SAAS,cAAc,MAAM;AAEnC,YAAM,YAAY,MAAM,UAAU,UAAU,KAAK;AACjD,YAAM,YAAY,WACf,KAAK,IAAI,GAAG,KAAK,MAAM,YAAY,CAAC,GAAG,OAAO,CAAC,KAAK,MAAM,MAAM,aAAa,EAAE,OAAO,MAAM,KAAK,GAAG,CAAC,CAAC,IACtG;AACH,YAAM,QAAQ,WAAW,SAAS,QAAQ,MAAM,QAAQ;AAExD,aACC,6CAAC,QAAmB,OAAO,YACzB;AAAA,oBAAY,SAAS,OAAO,aAAa,KAAK,MAAM,MACpD,6CAAC,SAAI,OAAO,eAAe,gBAAgB,cAAc,QAAQ,KAAK,QAAQ,CAAC,GAY7E;AAAA,0BAAgB,cAAc,QAAQ,KAAK,QAAQ,KACnD,4CAAC,SAAI,eAAY,QAAO,OAAO,EAAE,GAAG,UAAU,QAAQ,GAAG,MAAM,EAAE,GAChE;AAAA,YAAC;AAAA;AAAA,cACA,OAAO;AAAA,gBACN,YAAY;AAAA,gBACZ,QAAQ;AAAA,gBACR,cAAc;AAAA,gBACd,YAAY,KAAK,UAAU;AAAA,gBAC3B,OAAO,GAAG,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,QAAQ,KAAK,IAAI,GAAG,CAAC;AAAA,cACxD;AAAA;AAAA,UACD,GACD;AAAA,UAMD,4CAAC,gCAAK,MAAM,GAAG,OAAK,MAAE,mBAAS,OAAO,aAAa,KAAK,GAAE;AAAA,WAC3D;AAAA,QAGD;AAAA,UAAC;AAAA;AAAA,YACA,KAAK,CAAC,OAA8B;AACnC,mBAAK,QAAQ,KAAK,IAAI;AAAA,YACvB;AAAA,YACA,UAAU;AAAA,YACV,MAAK;AAAA,YACL,OAAO,YAAY,MAAM;AAAA,YACzB,cAAc,MAAM,aAAa,MAAM,GAAG;AAAA,YAC1C,cAAc,MAAM,aAAa,IAAI;AAAA,YACrC,SAAS,MAAM,aAAa,MAAM,GAAG;AAAA,YACrC,QAAQ,MAAM,aAAa,IAAI;AAAA,YAC/B,WAAW,CAAC,MAAM,UAAU,GAAG,KAAK;AAAA,YAEpC;AAAA,2DAAC,SAAI,OAAO,WACX;AAAA,4DAAC,gCAAK,MAAM,GAAG,QAAO,UAAU,gBAAM,OAAM;AAAA,gBAC5C,4CAAC,gCAAK,MAAM,GAAG,QAAO,YAAY,sBAAY,MAAM,KAAK,GAAE;AAAA,iBAC5D;AAAA,cAaC,MAAM,UACJ,4CAAC,SAAI,OAAO,aAAa,IAE1B,4CAAC,SAAI,eAAY,QAAO,OAAO,EAAE,GAAG,UAAU,QAAQ,GAAG,GACxD;AAAA,gBAAC;AAAA;AAAA,kBACA,OAAO;AAAA,oBACN,GAAG;AAAA,oBACH,OAAO,GAAG,KAAK;AAAA,oBACf,SAAS;AAAA,oBACT,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA,oBAKV,GAAI,WAAW,EAAE,YAAY,cAAc,IAAI,CAAC;AAAA,kBACjD;AAAA,kBAKE;AAAA,2BAAM,YAAY,CAAC,GAAG,IAAI,CAAC,SAAS,iBACrC;AAAA,sBAAC;AAAA;AAAA,wBAEA,OAAO;AAAA,0BACN,OAAO,GAAG,aAAa,QAAQ,OAAO,MAAM,KAAK,IAAI,GAAG;AAAA,0BACxD,YAAY,cAAc,YAAY;AAAA,0BACtC,QAAQ;AAAA,wBACT;AAAA;AAAA,sBALK,QAAQ;AAAA,oBAMd,CACA;AAAA,oBAIA,YAAY,YAAY,KACxB,4CAAC,SAAI,OAAO,EAAE,OAAO,GAAG,YAAY,GAAG,KAAK,YAAY,KAAK,WAAW,GAAG,QAAQ,OAAO,GAAG;AAAA;AAAA;AAAA,cAE/F,GACD;AAAA,cAsBD,SAAS,OAAO,OAAO,OAAO,OAAO,OAAO,CAAC,GAAG,SAAS,EAAE;AAAA;AAAA;AAAA,QAC7D;AAAA,WA5HQ,MAAM,GA6Hf;AAAA,IAEF,CAAC,GACF;AAAA,IAIC,YAAY,MAAM,EAAE,SAAS,KAC7B,4CAAC,SAAI,OAAO,kBACV,sBAAY,MAAM,EAAE,IAAI,CAAC,SAAS,UAClC,6CAAC,gCAAuB,MAAM,GAAG,OAAK,MACrC;AAAA,kDAAC,UAAK,eAAY,QAAO,OAAO,EAAE,GAAG,aAAa,YAAY,cAAc,KAAK,GAAG,SAAS,gBAAgB,aAAa,EAAE,GAAG;AAAA,MAC9H,QAAQ;AAAA,SAFC,QAAQ,GAGnB,CACA,GACF;AAAA,IAEA,OAAO,KAAK,CAAC,UAAU,MAAM,OAAO,KACpC,4CAAC,gCAAK,MAAM,GAAG,OAAK,MAE2D,4MAI/E;AAAA,IAEA,eACA,6CAAC,gCAAK,MAAM,GAAG,OAAK,MAAC;AAAA;AAAA,MACsB;AAAA,MAAqB;AAAA,OAEhE;AAAA,KAED;AAEF;AAYA,SAAS,SAAS,OAAe,aAAgD,QAAQ,GAAW;AASnG,MAAI,gBAAgB,cAAc,SAAS,EAAG,QAAO;AACrD,MAAI,UAAU,EAAG,QAAO;AACxB,MAAI,QAAQ,GAAG;AAId,UAAM,QAAQ,QAAQ,IAAI,WAAM,cAAc,QAAQ,OAAO,CAAC,CAAC,4BAA4B;AAC3F,WAAO,SAAI,YAAY,KAAK,CAAC,oBAAoB,KAAK;AAAA,EACvD;AAKA,SAAO,GAAG,YAAY,CAAC,KAAK,CAAC;AAC9B;AAQA,IAAM,cAAmC,EAAE,QAAQ,GAAG;AAGtD,IAAM,mBAAwC,EAAE,SAAS,QAAQ,KAAK,MAAM,OAAO,UAAU,QAAQ,YAAY,MAAM,KAAK;AAG5H,IAAM,YAAiC;AAAA,EACtC,SAAS;AAAA,EACT,qBAAqB;AAAA,EACrB,KAAK,MAAM;AAAA,EACX,YAAY;AACb;AAGA,IAAM,cAAmC;AAAA,EACxC,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,UAAU;AAAA,EACV,YAAY,KAAK,WAAW;AAC7B;AAGA,IAAM,cAAmC,EAAE,WAAW,SAAS,oBAAoB,gBAAgB,YAAY,SAAS;AAGxH,IAAM,cAAmC,EAAE,SAAS,IAAI;AAGxD,IAAM,aAAkC,EAAE,WAAW,QAAQ,QAAQ,GAAG,SAAS,EAAE;AAGnF,IAAM,aAAkC,EAAE,SAAS,QAAQ,KAAK,EAAE;AAQlE,SAAS,eAAe,QAAsC;AAC7D,SAAO;AAAA,IACN,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,gBAAgB,SAAS,aAAa;AAAA,IACtC,KAAK;AAAA,IACL,SAAS;AAAA,EACV;AACD;AAKA,SAAS,YAAY,QAAsC;AAC1D,SAAO;AAAA,IACN,SAAS;AAAA,IACT,KAAK;AAAA,IACL,SAAS;AAAA,IACT,cAAc;AAAA,IACd,QAAQ,aAAa,SAAS,iBAAiB,aAAa;AAAA,IAC5D,YAAY,SAAS,iDAAiD;AAAA,IACtE,QAAQ;AAAA,EACT;AACD;AAgBA,IAAM,UAA+B;AAAA,EACpC,QAAQ;AAAA,EACR,cAAc;AAAA;AAAA;AAAA;AAAA,EAId,YAAY,KAAK,UAAU;AAC5B;AAGA,IAAM,WAAgC;AAAA,EACrC,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,UAAU;AAAA;AAAA;AAAA,EAGV,YAAY,KAAK,WAAW;AAC7B;AAKA,IAAM,YAAiC;AAAA,EACtC,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,gBAAgB;AAAA,EAChB,KAAK;AAAA,EACL,UAAU;AACX;AAIA,IAAM,aAAkC;AAAA,EACvC,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,SAAS;AAAA,EACT,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AAAA,EACT,gBAAgB;AAAA,EAChB,qBAAqB;AAAA,EACrB,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,OAAO;AACR;AAGA,IAAM,YAAiC;AAAA,EACtC,SAAS;AAAA,EACT,KAAK;AAAA,EACL,YAAY;AAAA,EACZ,UAAU;AACX;AAGA,IAAM,cAAmC,EAAE,MAAM,WAAW;AAWrD,SAAS,WAAW,EAAE,QAAQ,GAA+C;AACnF,QAAM,CAAC,UAAU,WAAW,IAAI,cAAAC,QAAM,SAAS,KAAK;AAEpD,MAAI,QAAQ,WAAW,EAAG,QAAO;AAMjC,QAAM,cAAc,QAAQ,MAAM,GAAG,CAAC;AACtC,QAAM,SAAS,QAAQ,MAAM,CAAC;AAE9B,QAAM,OAAO,CAAC,WACb,4CAAC,QACA,sDAAC,gCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,WAAU,QAAM,MAKjD,uDAAC,SAAI,OAAO,WACX;AAAA,gDAAC,UAAK,OAAO,aACZ,sDAAC,iCAAM,MAAK,WAAU,UAAU,GAAG,kBAAI,GACxC;AAAA,IACA,4CAAC,gCAAK,MAAM,GAAI,kBAAO;AAAA,KACxB,GACD,KAZQ,MAaT;AAGD,SACC,6CAAC,iCAAM,OAAO,GAEb;AAAA,iDAAC,QAAG,OAAO,EAAE,WAAW,QAAQ,QAAQ,GAAG,SAAS,GAAG,SAAS,QAAQ,KAAK,EAAE,GAC7E;AAAA,kBAAY,IAAI,IAAI;AAAA,MACpB,YAAY,OAAO,IAAI,IAAI;AAAA,OAC7B;AAAA,IAEC,OAAO,SAAS,KAChB;AAAA,MAAC;AAAA;AAAA,QACA,MAAK;AAAA,QACL,OAAO;AAAA,QACP,SAAS,MAAM,YAAY,CAAC,SAAS,CAAC,IAAI;AAAA,QAC1C,iBAAe;AAAA,QAEd,qBACE,uBACA,GAAG,OAAO,MAAM,SAAS,OAAO,WAAW,IAAI,WAAW,SAAS;AAAA;AAAA,IACvE;AAAA,KAEF;AAEF;AAoDO,SAAS,cAAmB,QAAyB,MAAsB;AACjF,MAAI,OAAO,WAAY,QAAO;AAG9B,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,SAAO,KAAK,MAAM,CAAC,QAAQ;AAC1B,UAAM,QAAQ,OAAO,UAAU,GAAG;AAClC,WAAO,UAAU,KAAK,UAAU;AAAA,EACjC,CAAC;AACF;AAgBO,SAAS,QAAQ;AAAA,EACvB;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,cAAc;AAAA,EACd,cAAc;AAAA,EACd;AACD,GAWuB;AACtB,QAAM,CAAC,MAAM,OAAO,IAAI,cAAAA,QAAM,SAAS,WAAW;AAClD,QAAM,SAAS,cAAAA,QAAM,MAAM;AAC3B,QAAM,QAAQ,CAAC,eAAe;AAE9B,SACC,6CAAC,aAAQ,OAAO,YACf;AAAA,iDAAC,SAAI,OAAO,eACX;AAAA,mDAAC,iCAAM,OAAO,GACb;AAAA,oDAAC,mCAAQ,MAAM,SAAS,YAAY,IAAI,GAAG,OAAO,SAAS,YAAY,eAAe,gBACpF,iBACF;AAAA,QACC,YAAY,4CAAC,gCAAK,MAAM,GAAG,OAAK,MAAE,oBAAS;AAAA,SAC7C;AAAA,MACC,eACA;AAAA,QAAC;AAAA;AAAA,UACA,MAAK;AAAA,UACL,OAAO;AAAA,UACP,iBAAe;AAAA,UACf,iBAAe;AAAA,UACf,SAAS,MAAM,QAAQ,CAAC,QAAQ,CAAC,GAAG;AAAA,UAEnC,iBAAO,SAAS;AAAA;AAAA,MAClB;AAAA,OAEF;AAAA,IAEC,SAAS,4CAAC,SAAI,IAAI,QAAQ,OAAO,aAAc,UAAS;AAAA,KAC1D;AAEF;AAUO,SAAS,aAAa;AAAA,EAC5B;AAAA,EACA,OAAO;AACR,GAIuB;AACtB,SACC,4CAAC,SAAI,OAAO,eACX,sDAAC,mCAAQ,MAAM,SAAS,YAAY,IAAI,GAAG,OAAO,SAAS,YAAY,eAAe,gBACpF,iBACF,GACD;AAEF;AA+BO,SAAS,cAAc,OAAgC,MAAwC;AACrG,QAAM,IAAI,YAAY,KAAK;AAC3B,QAAM,IAAI,YAAY,IAAI;AAC1B,SAAO,MAAM,QAAQ,MAAM,QAAQ,IAAI,KAAK,KAAK;AAClD;AAEO,SAAS,eAAe;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD,GAO8B;AAC7B,QAAM,aAAa,YAAY,KAAK;AACpC,QAAM,YAAY,YAAY,IAAI;AAGlC,MAAI,CAAC,cAAc,OAAO,IAAI,EAAG,QAAO;AACxC,MAAI,eAAe,QAAQ,cAAc,KAAM,QAAO;AAEtD,QAAM,QAAQ,YAAY;AAC1B,QAAM,UAAU,aAAa;AAE7B,SACC,6CAAC,iCAAM,OAAO,GACb;AAAA,iDAAC,SAAI,OAAO,WACX;AAAA,kDAAC,gCAAK,MAAM,GAAI,sBAAW;AAAA,MAC3B,4CAAC,gCAAK,MAAM,GAAG,QAAO,UAAU,sBAAY,UAAU,GAAE;AAAA,OACzD;AAAA,IAGA,4CAAC,SAAI,eAAY,QAAO,OAAO,EAAE,GAAG,UAAU,YAAY,KAAK,UAAU,GAAG,QAAQ,GAAG,GACtF,sDAAC,SAAI,OAAO,EAAE,QAAQ,QAAQ,OAAO,GAAG,QAAQ,GAAG,KAAK,YAAY,KAAK,UAAU,GAAG,cAAc,EAAE,GAAG,GAC1G;AAAA,IACA,6CAAC,SAAI,OAAO,WACX;AAAA,mDAAC,gCAAK,MAAM,GAAG,OAAK,MAClB;AAAA;AAAA,QAAU;AAAA,QAAG,YAAY,SAAS;AAAA,QAAE;AAAA,QAAG,cAAc,OAAO,CAAC;AAAA,QAAE;AAAA,SACjE;AAAA,MACA,6CAAC,gCAAK,MAAM,GAAG,OAAK,MAClB;AAAA,oBAAY,OAAO;AAAA,QAAE;AAAA,QAAE;AAAA,SACzB;AAAA,OACD;AAAA,KACD;AAEF;AA+BO,SAAS,gBAA2C;AAAA,EAC1D;AAAA,EACA;AAAA,EACA;AACD,GAY8B;AAC7B,MAAI,UAAU,WAAW,EAAG,QAAO;AAEnC,QAAM,YAAY,UAAU,IAAI,CAAC,MAAM,YAAY,CAAC,CAAC;AAGrD,QAAM,MAAM,KAAK,IAAI,GAAG,GAAG,UAAU,IAAI,CAAC,MAAM,EAAE,IAAI,GAAG,GAAG,UAAU,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AACxF,QAAM,MAAM,CAAC,MAAc,GAAI,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,IAAI,GAAG,CAAC,IAAK,GAAG;AAEvE,SACC,6CAAC,iCAAM,OAAO,GACZ;AAAA,cAAU,IAAI,CAAC,UAAU,UAAU;AACnC,YAAM,WAAW,UAAU,KAAK;AAChC,YAAM,OAAO,SAAS,OAAO,SAAS,MAAM;AAC5C,aACC,6CAAC,iCAA2B,OAAO,GAClC;AAAA,qDAAC,SAAI,OAAO,WACX;AAAA,sDAAC,gCAAK,MAAM,GAAI,mBAAS,SAAS,KAAK,GAAE;AAAA,UACzC,4CAAC,gCAAK,MAAM,GAAG,QAAO,UAAU,wBAAc,SAAS,MAAM,CAAC,GAAE;AAAA,WACjE;AAAA,QACA,6CAAC,SAAI,eAAY,QAAO,OAAO,eAE9B;AAAA,sDAAC,SAAI,OAAO,EAAE,GAAG,cAAc,MAAM,IAAI,CAAC,EAAE,GAAG;AAAA,UAC/C;AAAA,YAAC;AAAA;AAAA,cACA,OAAO;AAAA,gBACN,GAAG;AAAA,gBACH,MAAM,IAAI,SAAS,GAAG;AAAA,gBACtB,OAAO,QAAQ,IAAI,SAAS,IAAI,CAAC,MAAM,IAAI,SAAS,GAAG,CAAC;AAAA,cACzD;AAAA;AAAA,UACD;AAAA,UACA,4CAAC,SAAI,OAAO,EAAE,GAAG,aAAa,MAAM,IAAI,SAAS,IAAI,EAAE,GAAG;AAAA,WAC3D;AAAA,QACA,6CAAC,gCAAK,MAAM,GAAG,OAAK,MAClB;AAAA,sBAAY,SAAS,QAAQ;AAAA,UAAE;AAAA,UAAK,YAAY,SAAS,MAAM;AAAA,UAG/D,QAAQ;AAAA,UAWR,SAAS,QAAQ,4CAAC,UAAK,OAAO,cAAe,mBAAS,MAAK;AAAA,UAK3D,SAAS,OAAO,QAAQ;AAAA,WAC1B;AAAA,WAtCW,SAAS,KAuCrB;AAAA,IAEF,CAAC;AAAA,IAID,4CAAC,gCAAK,MAAM,GAAG,OAAK,MAAC,kCAAoB;AAAA,KAC1C;AAEF;AA6BO,SAAS,cAAc,EAAE,MAAM,SAAS,GAGjB;AAC7B,MAAI,KAAK,WAAW,EAAG,QAAO;AAI9B,QAAM,OAAO,KAAK,IAAI,GAAG,GAAG,KAAK,IAAI,CAAC,QAAQ,IAAI,MAAM,CAAC;AAEzD,QAAM,QAA4D,CAAC;AACnE,aAAW,OAAO,MAAM;AACvB,UAAM,QAAQ,MAAM,KAAK,CAAC,MAAM,EAAE,SAAS,IAAI,IAAI;AACnD,QAAI,MAAO,OAAM,MAAM,KAAK,GAAG;AAAA,QAC1B,OAAM,KAAK,EAAE,MAAM,IAAI,MAAM,OAAO,CAAC,GAAG,EAAE,CAAC;AAAA,EACjD;AAEA,SACC,4CAAC,iCAAM,OAAO,GACZ,gBAAM,IAAI,CAAC,UACX,6CAAC,iCAAuB,OAAO,GAC9B;AAAA,gDAAC,gCAAK,MAAM,GAAG,QAAO,YAAY,gBAAM,MAAK;AAAA,IAC5C,MAAM,MAAM,IAAI,CAAC,KAAK,UAAU;AAGhC,YAAM,kBAAkB,QAAQ,KAAK,MAAM,MAAM,QAAQ,CAAC,GAAG,SAAS,IAAI;AAC1E,aACC,6CAAC,SAAyC,OAAO,WAChD;AAAA,oDAAC,gCAAK,MAAM,GAAG,OAAK,MAAE,4BAAkB,KAAK,IAAI,MAAK;AAAA,QACtD,4CAAC,gCAAK,MAAM,GAAG,OAAK,MAAE,cAAI,MAAK;AAAA,QAC/B,4CAAC,SAAI,eAAY,QAAO,OAAO,aAC9B,sDAAC,SAAI,OAAO,EAAE,GAAG,SAAS,OAAO,GAAG,KAAK,IAAI,GAAI,IAAI,SAAS,OAAQ,GAAG,CAAC,IAAI,GAAG,GAClF;AAAA,QACA,6CAAC,gCAAK,MAAM,GAAG,OAAO,aACpB;AAAA,sBAAY,IAAI,MAAM;AAAA,UACtB,IAAI,YAAY,QAChB,6CAAC,UAAK,OAAO,aAAa;AAAA;AAAA,YAAI,YAAY,IAAI,SAAS,QAAQ;AAAA,aAAE;AAAA,WAEnE;AAAA,WAXS,GAAG,IAAI,IAAI,KAAS,IAAI,IAAI,EAYtC;AAAA,IAEF,CAAC;AAAA,OArBU,MAAM,IAsBlB,CACA,GACF;AAEF;AAmBO,SAAS,YAAY;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,aAAAC;AACD,GAUuB;AACtB,QAAM,CAAC,WAAW,YAAY,IAAI,cAAAD,QAAM,SAAS,KAAK;AAGtD,QAAM,YAAY,UAAU,QAAQ,MAAM,MAAM,CAAC,MAAM,EAAE,UAAU,IAAI;AAEvE,SACC,4CAAC,iCAAM,OAAO,GACb,uDAAC,SAAI,OAAO,UACV;AAAA,iBAAa,YAEZ,4CAAC,iCAAM,OAAO,GACZ,gBAAM,IAAI,CAAC,SACX,6CAAC,gCAAsB,MAAM,OAAO,IAAI,IAAI,MACzC;AAAA,YAAK,UAAU,aAAa,KAAK,KAAe;AAAA,MAAG;AAAA,MACrD,4CAAC,gCAAK,IAAG,QAAO,MAAM,GAAG,OAAK,MAAE,eAAK,OAAM;AAAA,SAFjC,KAAK,KAGhB,CACA,GACF,IAEC,UAAU,OACTC,eACA,4CAAC,gCAAK,MAAa,iBAAO,KAAK,GAAE;AAAA,IACpC,aACA;AAAA,MAAC;AAAA;AAAA,QACA,MAAK;AAAA,QACL,OAAO;AAAA,QACP,gBAAc;AAAA,QAGd,cAAY,YAAY,kBAAkB;AAAA,QAC1C,SAAS,MAAM,aAAa,CAAC,QAAQ,CAAC,GAAG;AAAA,QAExC,sBAAY,SAAS;AAAA;AAAA,IACvB;AAAA,KAEF,GACD;AAEF;AAuCO,SAAS,cAAmB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD,GAAgD;AAC/C,QAAM,CAAC,MAAM,OAAO,IAAI,cAAAD,QAAM;AAAA,IAC7B,cAAc,EAAE,KAAK,aAAa,MAAM,KAAK,IAAI;AAAA,EAClD;AACA,QAAM,CAAC,OAAO,QAAQ,IAAI,cAAAA,QAAM,SAAS,EAAE;AAC3C,QAAM,CAAC,UAAU,WAAW,IAAI,cAAAA,QAAM,SAA8B,MAAM,oBAAI,IAAI,CAAC;AACnF,QAAM,CAAC,QAAQ,SAAS,IAAI,cAAAA,QAAM,SAAqC,MAAM;AAG7E,QAAM,CAAC,WAAW,YAAY,IAAI,cAAAA,QAAM,SAAS,KAAK;AAGtD,QAAM,YAAY,cAAAA,QAAM,OAAsB,IAAI;AAClD,gBAAAA,QAAM,UAAU,MAAM,MAAM;AAC3B,QAAI,UAAU,YAAY,KAAM,QAAO,aAAa,UAAU,OAAO;AAAA,EACtE,GAAG,CAAC,CAAC;AAEL,QAAM,SAAS,OAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,QAAQ,KAAK,GAAG,IAAI;AAGhE,QAAM,UAAU,cAAAA,QAAM,QAAQ,MAAM;AACnC,UAAM,SAAS,MAAM,KAAK,EAAE,YAAY;AACxC,WAAO,KAAK,OAAO,CAAC,QAAQ;AAC3B,UAAI,SAAS,IAAI,OAAO,GAAG,CAAC,EAAG,QAAO;AACtC,UAAI,CAAC,UAAU,CAAC,SAAU,QAAO;AACjC,aAAO,SAAS,GAAG,EAAE,YAAY,EAAE,SAAS,MAAM;AAAA,IACnD,CAAC;AAAA,EACF,GAAG,CAAC,MAAM,OAAO,UAAU,UAAU,MAAM,CAAC;AAU5C,QAAM,eAAe,cAAAA,QAAM;AAAA,IAC1B,MAAM,QAAQ;AAAA,MAAO,CAAC,QAAQ,UAC7B,QAAQ,KAAK,OAAO,QAAQ,MAAM,OAAO,cAAc,QAAQ,OAAO;AAAA,IACvE;AAAA,IACA,CAAC,SAAS,SAAS,MAAM,GAAG;AAAA,EAC7B;AACA,QAAM,QAAQ,aAAa,aAAa,WAAW,IAChD,UACA,QAAQ,OAAO,CAAC,WAAW,CAAC,aAAa,SAAS,MAAM,CAAC;AAE5D,QAAM,UAAU,cAAAA,QAAM,QAAQ,MAAM;AACnC,UAAME,QAAO;AACb,QAAI,CAAC,UAAU,CAAC,KAAM,QAAOA;AAC7B,UAAM,OAAO,CAAC,GAAGA,KAAI;AACrB,SAAK,KAAK,CAAC,GAAG,MAAM;AACnB,YAAM,KAAK,OAAO,UAAU,CAAC;AAC7B,YAAM,KAAK,OAAO,UAAU,CAAC;AAE7B,UAAI,OAAO,QAAQ,OAAO,KAAM,QAAO;AACvC,UAAI,OAAO,KAAM,QAAO;AACxB,UAAI,OAAO,KAAM,QAAO;AACxB,YAAM,MAAM,OAAO,OAAO,YAAY,OAAO,OAAO,WACjD,KAAK,KACL,OAAO,EAAE,EAAE,cAAc,OAAO,EAAE,CAAC;AACtC,aAAO,KAAK,OAAO,CAAC,MAAM;AAAA,IAC3B,CAAC;AACD,WAAO;AAAA,EACR,GAAG,CAAC,SAAS,QAAQ,IAAI,CAAC;AAE1B,QAAM,SAAS,CAAC,WAA4B;AAC3C,YAAQ,CAAC,YAAY;AACpB,UAAI,SAAS,QAAQ,OAAO,IAAK,QAAO,EAAE,KAAK,OAAO,KAAK,MAAM,QAAQ,OAAO,OAAO,EAAE;AACzF,aAAO,EAAE,KAAK,OAAO,KAAK,MAAM,CAAC,QAAQ,KAAK;AAAA,IAC/C,CAAC;AAAA,EACF;AAUA,QAAM,UAAU,YAAY;AAG3B,UAAM,SAAS,MAAM,IAAI,CAAC,WAAW,OAAO,KAAK;AACjD,UAAM,QAAQ,CAAC,QAAQ,GAAG,QAAQ,IAAI,CAAC,QAAQ,MAAM,IAAI,CAAC,WAAW;AAMpE,YAAM,QAAQ,OAAO,cAAc,OAAO,YAAY,GAAG,IAAI,OAAO,UAAU,GAAG;AACjF,aAAO,UAAU,QAAQ,UAAU,SAAY,KAAK,OAAO,KAAK;AAAA,IACjE,CAAC,CAAC,CAAC;AAEH,UAAM,MAAM,MAAM,IAAI,CAAC,UAAU,MAAM,IAAI,OAAO,EAAE,KAAK,GAAG,CAAC,EAAE,KAAK,IAAI;AAExE,QAAI;AACH,YAAM,UAAU,UAAU,UAAU,GAAG;AACvC,gBAAU,MAAM;AAChB,UAAI,UAAU,YAAY,KAAM,QAAO,aAAa,UAAU,OAAO;AACrE,gBAAU,UAAU,OAAO,WAAW,MAAM,UAAU,MAAM,GAAG,GAAI;AAAA,IACpE,QAAQ;AAIP,gBAAU,QAAQ;AAClB,UAAI,UAAU,YAAY,KAAM,QAAO,aAAa,UAAU,OAAO;AACrE,gBAAU,UAAU,OAAO,WAAW,MAAM,UAAU,MAAM,GAAG,GAAI;AAAA,IACpE;AAAA,EACD;AAMA,QAAM,mBAAmB,KAAK,OAAO,CAAC,QAAQ,SAAS,IAAI,OAAO,GAAG,CAAC,CAAC,EAAE;AACzE,QAAM,cAAc,KAAK,SAAS,QAAQ,SAAS;AAKnD,QAAM,gBAAgB,SAAS;AAE/B,SACC,6CAAC,iCAAM,OAAO,GACX;AAAA,iBAAY,cAAc,aAAa,SAAS,MACjD,6CAAC,SAAI,OAAO,eACV;AAAA,kBACA;AAAA,QAAC;AAAA;AAAA,UACA,MAAK;AAAA,UACL,OAAO;AAAA,UACP,aAAa,qBAAqB;AAAA,UAClC,cAAY,qBAAqB;AAAA,UACjC,OAAO;AAAA,UACP,UAAU,CAAC,MAAM,SAAS,EAAE,cAAc,KAAK;AAAA;AAAA,MAChD;AAAA,OAEC,cAAc,KAAK,gBAAgB,MACpC,6CAAC,gCAAK,MAAM,GAAG,OAAK,MAClB;AAAA,sBAAc,KAAK,4EAAG;AAAA;AAAA,UAAY;AAAA,UAAK,gBAAgB,IAAI,KAAK;AAAA,UAAI;AAAA,WAAO;AAAA,QAC3E,gBAAgB,KAChB,4EACE;AAAA,wBAAc,IAAI,WAAQ;AAAA,UAC1B;AAAA,UAAc;AAAA,UAAU;AAAA,UACzB,4CAAC,YAAO,MAAK,UAAS,OAAO,YAAY,SAAS,MAAM,YAAY,oBAAI,IAAI,CAAC,GAAG,qBAEhF;AAAA,WACD;AAAA,SAEF;AAAA,MAED,4CAAC,UAAK,OAAO,EAAE,MAAM,EAAE,GAAG;AAAA,MAIzB,aAAa,SAAS,KACtB;AAAA,QAAC;AAAA;AAAA,UACA,MAAK;AAAA,UACL,OAAO;AAAA,UACP,gBAAc;AAAA,UACd,SAAS,MAAM,aAAa,CAAC,SAAS,CAAC,IAAI;AAAA,UAE1C,sBACE,QAAQ,aAAa,MAAM,gBAAgB,aAAa,WAAW,IAAI,KAAK,GAAG,KAC/E,QAAQ,aAAa,MAAM,gBAAgB,aAAa,WAAW,IAAI,KAAK,GAAG;AAAA;AAAA,MACnF;AAAA,MAEA,cACA,4CAAC,YAAO,MAAK,UAAS,OAAO,oBAAoB,SAAS,MAAM,KAAK,QAAQ,GAC3E,qBAAW,SAAS,WAAW,WAAW,WAAW,mBAAmB,eAC1E;AAAA,OAEF;AAAA,IAGF,4CAAC,gCAAK,QAAQ,GAAG,MAAK,eAAc,OAAO,cAC1C,uDAAC,WAAM,OAAO,WACb;AAAA,kDAAC,aAAQ,OAAO,gBAAiB,mBAAQ;AAAA,MACzC,4CAAC,WACA,sDAAC,QACC,gBAAM,IAAI,CAAC,WAAW;AACtB,cAAM,WAAW,MAAM,QAAQ,OAAO;AACtC,eACC;AAAA,UAAC;AAAA;AAAA,YAEA,OAAM;AAAA,YACN,OAAO,OAAO,UAAU,kBAAkB;AAAA,YAC1C,aAAW,WAAY,MAAM,OAAO,eAAe,cAAe;AAAA,YAElE,uDAAC,YAAO,MAAK,UAAS,OAAO,WAAW,QAAQ,OAAO,OAAO,CAAC,GAAG,SAAS,MAAM,OAAO,MAAM,GAC5F;AAAA,qBAAO;AAAA,cAGR,4CAAC,UAAK,eAAY,QAAO,OAAO,UAC9B,qBAAY,MAAM,OAAO,WAAM,WAAO,UACxC;AAAA,eACD;AAAA;AAAA,UAZK,OAAO;AAAA,QAab;AAAA,MAEF,CAAC,GACF,GACD;AAAA,MACA,6CAAC,WACC;AAAA,gBAAQ,IAAI,CAAC,QAAQ;AACrB,gBAAM,MAAM,OAAO,GAAG;AACtB,iBACC,4CAAC,QACC,gBAAM,IAAI,CAAC,QAAQ,UAAU;AAC7B,kBAAM,UAAU,OAAO,OAAO,GAAG;AACjC,mBAAO,UAAU,IAChB,4CAAC,QAAoB,OAAM,OAAM,OAAO,UACvC,uDAAC,UAAK,OAAO,WACX;AAAA;AAAA,cAKA,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA,gBAKZ;AAAA,kBAAC;AAAA;AAAA,oBACA,MAAK;AAAA,oBACL,OAAO;AAAA,oBACP,cAAY,WAAW,GAAG;AAAA,oBAC1B,OAAO,WAAW,GAAG;AAAA,oBACrB,SAAS,MAAM,YAAY,CAAC,YAAY,IAAI,IAAI,OAAO,EAAE,IAAI,GAAG,CAAC;AAAA,oBACjE;AAAA;AAAA,gBAED;AAAA,kBACG;AAAA,eACL,KAtBQ,OAAO,GAuBhB,IAEA,4CAAC,QAAoB,OAAO,OAAO,UAAU,kBAAkB,UAAW,qBAAjE,OAAO,GAAkE;AAAA,UAEpF,CAAC,KA/BO,GAgCT;AAAA,QAEF,CAAC;AAAA,QACA,QAAQ,WAAW,KACnB,4CAAC,QACA,sDAAC,QAAG,SAAS,QAAQ,QAAQ,OAAO,UAInC,sDAAC,gCAAK,MAAM,GAAG,OAAK,MAClB,eAAK,WAAW,IACd,qCACA,MAAM,KAAK,KAAK,mBAAmB,IAClC,+BACA,oCACL,GACD,GACD;AAAA,SAEF;AAAA,OACD,GACD;AAAA,IAEC,iBAAiB,4CAAC,gCAAK,MAAM,GAAG,OAAK,MAAE,yBAAc;AAAA,KACtD;AAEF;AAUA,SAAS,QAAQ,MAAsB;AACtC,QAAM,cAAc,eAAe,KAAK,IAAI,IAAI,IAAI,IAAI,KAAK;AAC7D,SAAO,SAAS,KAAK,WAAW,IAAI,IAAI,YAAY,QAAQ,MAAM,IAAI,CAAC,MAAM;AAC9E;AAGA,IAAM,gBAAqC;AAAA,EAC1C,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,KAAK,MAAM;AAAA,EACX,UAAU;AACX;AAGA,IAAM,aAAkC,EAAE,SAAS,QAAQ,KAAK,MAAM,MAAM;AAO5E,IAAM,cAAmC,EAAE,SAAS,QAAQ,KAAK,MAAM,MAAM;AAG7E,IAAM,gBAAqC;AAAA,EAC1C,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,gBAAgB;AAAA,EAChB,KAAK;AAAA;AAAA;AAAA;AAAA,EAIL,cAAc;AAAA,EACd,eAAe;AAChB;AAGA,IAAM,eAAoC,EAAE,QAAQ,GAAG,YAAY,IAAI;AAQvE,IAAM,iBAAsC;AAAA,EAC3C,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,eAAe;AAAA,EACf,SAAS;AACV;AAGA,IAAM,gBAAqC;AAAA,EAC1C,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,OAAO;AAAA,EACP,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,MAAM;AACP;AAkBA,IAAM,gBAAqC;AAAA,EAC1C,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,YAAY,KAAK,WAAW;AAC7B;AAGA,IAAM,eAAoC,EAAE,SAAS,SAAS,WAAW,GAAG,SAAS,KAAK;AAG1F,IAAM,eAAoC;AAAA,EACzC,UAAU;AAAA,EACV,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,YAAY,KAAK,eAAe;AACjC;AAGA,IAAM,mBAAwC;AAAA,EAC7C,UAAU;AAAA,EACV,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,YAAY,KAAK,mBAAmB;AACrC;AAGA,IAAM,cAAmC;AAAA,EACxC,UAAU;AAAA,EACV,KAAK;AAAA,EACL,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,YAAY,KAAK,cAAc;AAChC;AAYA,IAAM,WAAgC;AAAA,EACrC,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,KAAK,MAAM;AAAA,EACX,UAAU;AACX;AAGA,IAAM,cAAmC;AAAA,EACxC,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,OAAO;AAAA,EACP,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AACV;AAGA,IAAM,cAAmC;AAAA,EACxC,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AAAA,EACT,cAAc;AAAA,EACd,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,OAAO;AAAA,EACP,UAAU;AACX;AAGA,IAAM,qBAA0C;AAAA,EAC/C,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,OAAO;AAAA,EACP,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,YAAY;AACb;AAGA,IAAM,aAAkC;AAAA,EACvC,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AAAA,EACT,gBAAgB;AAAA,EAChB,qBAAqB;AAAA,EACrB,QAAQ;AACT;AAGA,IAAM,YAAiC;AAAA,EACtC,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,gBAAgB;AAAA,EAChB,KAAK;AACN;AAGA,IAAM,gBAAqC;AAAA,EAC1C,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,MAAM;AACP;AAGA,IAAM,eAAoC,EAAE,WAAW,QAAQ,OAAO,OAAO;AAG7E,IAAM,YAAiC,EAAE,OAAO,QAAQ,gBAAgB,YAAY,UAAU,IAAI;AAGlG,IAAM,WAAgC;AAAA,EACrC,SAAS;AAAA,EACT,WAAW;AAAA,EACX,cAAc;AAAA,EACd,YAAY;AACb;AAEA,IAAM,kBAAuC,EAAE,GAAG,UAAU,WAAW,QAAQ;AAE/E,IAAM,WAAgC;AAAA,EACrC,SAAS;AAAA,EACT,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,cAAc;AACf;AAEA,IAAM,kBAAuC;AAAA,EAC5C,GAAG;AAAA,EACH,WAAW;AAAA,EACX,oBAAoB;AACrB;AAGA,SAAS,WAAW,SAAuC;AAC1D,SAAO;AAAA,IACN,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ,QAAQ;AAAA,IACR,OAAO;AAAA,IACP,MAAM;AAAA,IACN,UAAU;AAAA,IACV,eAAe;AAAA,IACf,eAAe;AAAA,IACf,SAAS;AAAA,IACT,SAAS;AAAA,IACT,OAAO;AAAA,IACP,SAAS;AAAA,IACT,KAAK;AAAA,IACL,YAAY;AAAA,IACZ,gBAAgB,UAAU,aAAa;AAAA,IACvC,QAAQ;AAAA,EACT;AACD;AAGA,IAAM,WAAgC,EAAE,SAAS,KAAK,UAAU,QAAQ;AAGjE,IAAM,iBAAsC;AAAA,EAClD,UAAU;AAAA,EACV,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,MAAM;AAAA,EACN,YAAY;AACb;;;AJ3xEA,IAAAC,2BAAsB;;;AODtB,IAAAC,2BAA+D;;;ACO/D,IAAAC,gBAA8D;AAC9D,IAAAC,2BAAkC;AAClC,sBAAsC;AACtC,sBAA4D;AAC5D,sBAA6B;AA6tBG,IAAAC,sBAAA;AAvrBzB,SAAS,SAAS,QAA0B,MAAkB,UAAiC;AACrG,MAAI,SAAU,QAAO;AACrB,MAAI,WAAW,SAAU,QAAO;AAChC,MAAI,WAAW,SAAU,QAAO,SAAS,UAAU,YAAY;AAG/D,SAAO,SAAS,UAAU,iBAAiB;AAC5C;AA8HA,IAAM,QAAQ;AAGd,IAAM,kBAAkB;AAExB,IAAM,cAAc;AAEpB,IAAM,SAAS;AACf,IAAM,YAAY;AAClB,IAAM,UAAU;AAEhB,IAAM,YAAY;AAWlB,IAAM,cAAc;AAOpB,IAAM,iBAAiB;AAGvB,SAAS,YAAY,OAAe,MAAkB,UAA6C;AAClG,MAAI,SAAS,QAAS,QAAO,YAAY,OAAO,YAAY,IAAI;AAChE,MAAI,SAAS,UAAW,QAAO,cAAc,OAAO,CAAC;AACrD,SAAO,YAAY,KAAK;AACzB;AAGA,SAAS,UAAU,MAAoB;AACtC,SAAO,GAAG,KAAK,WAAW,CAAC,IAAI,KAAK,eAAe,SAAS,EAAE,OAAO,SAAS,UAAU,MAAM,CAAC,CAAC;AACjG;AAoBO,SAAS,WACf,SACA,MACA,UACA,cACA,WACA,OACgB;AAChB,MAAI,UAAU,KAAK,YAAY,KAAK,aAAa,EAAG,QAAO;AAC3D,QAAM,SAAU,UAAU,QAAQ,WAAY;AAC9C,QAAM,UAAU,KAAK,IAAI,QAAQ,KAAK,IAAI,SAAS,WAAW,KAAK,CAAC;AACpE,QAAM,QAAQ,KAAK,OAAQ,UAAU,UAAU,aAAc,QAAQ,EAAE;AACvE,SAAO,KAAK,IAAI,GAAG,KAAK,IAAI,QAAQ,GAAG,KAAK,CAAC;AAC9C;AAgCO,SAAS,qBACf,MACA,OAaA,UAAgC,CAAC,GACP;AAE1B,QAAM,UAAU,QAAQ,OAAe,CAAC,MAAM,MAAM,KAAK,IAAI,MAAM,KAAK,CAAC,GAAG,CAAC;AAC7E,QAAM,QAAQ,UAAU,IAAI,KAAK,IAAI,GAAG,UAAU,GAAG,IAAI;AACzD,QAAM,SAAS,KAAK,IAAI,CAAC,OAAO,MAAM;AACrC,UAAM,SAAS,QAAQ,CAAC;AACxB,QAAI,QAAQ,MAAM,WAAW,QAAQ,WAAW,UAAa,SAAS,OAAQ,QAAO;AACrF,WAAO;AAAA,EACR,CAAC;AACD,SAAO;AAEP,QAAM,WAAW,KAAK,OAAO,CAAC,MAAmB,MAAM,IAAI;AAG3D,MAAI,SAAS,SAAS,GAAI,QAAO;AAEjC,QAAM,SAAS,CAAC,GAAG,QAAQ,EAAE,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC;AAUjD,QAAM,SAAS,OAAO,KAAK,IAAI,OAAO,SAAS,GAAG,KAAK,MAAM,OAAO,SAAS,IAAI,CAAC,CAAC;AAGnF,MAAI,UAAU,IAAK,QAAO;AAE1B,QAAM,YAAY,SAAS;AAC3B,QAAM,UAAU;AAKhB,QAAM,UAAU;AAKhB,QAAM,OAA8C,CAAC;AACrD,MAAI,WAA0B;AAC9B,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,KAAK,KAAK,QAAQ,KAAK;AACtC,UAAM,QAAQ,IAAI,KAAK,SAAS,KAAK,CAAC,IAAI;AAC1C,UAAM,MAAM,UAAU,QAAQ,UAAU,UAAa,QAAQ;AAC7D,UAAM,aAAa,IAAI,KAAK,WAAW,UAAU,QAAQ,UAAU;AAEnE,QAAI,KAAK;AACR,UAAI,aAAa,KAAM,YAAW;AAClC,YAAM;AAAA,IACP,WAAW,cAAc,aAAa,QAAQ,MAAM,SAAS;AAC5D,aAAO;AAAA,IACR,WAAW,aAAa,MAAM;AAE7B,WAAK,KAAK,EAAE,OAAO,UAAU,KAAK,IAAI,IAAI,CAAC;AAC3C,iBAAW;AACX,YAAM;AAAA,IACP;AAAA,EACD;AACA,OAAK,KAAK,CAAC,GAAG,MAAO,EAAE,MAAM,EAAE,SAAU,EAAE,MAAM,EAAE,MAAM;AAEzD,QAAM,OAAO,CAAC,WAAqB,OAAO,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC,IAAI,OAAO;AAElF,aAAW,OAAO,MAAM;AACvB,QAAI,IAAI,MAAM,IAAI,QAAQ,QAAS;AACnC,UAAM,SAAS,KAAK,MAAM,IAAI,OAAO,IAAI,GAAG,EAAE,OAAO,CAAC,MAAmB,MAAM,IAAI;AACnF,UAAM,SAAS,KAAK,MAAM,GAAG,IAAI,KAAK,EAAE,OAAO,CAAC,MAAmB,MAAM,IAAI;AAE7E,QAAI,OAAO,SAAS,KAAK,OAAO,WAAW,EAAG;AAE9C,UAAM,QAAQ,MAAM,IAAI,KAAK;AAC7B,QAAI,CAAC,MAAO;AAEZ,UAAM,aAAa,KAAK,MAAM;AAC9B,UAAM,QAAQ,KAAK,MAAM,IAAI,GAAG,EAAE,OAAO,CAAC,MAAmB,MAAM,IAAI;AASvE,UAAM,YAAY,MAAM,UAAU,WAAW,KAAK,KAAK,KAAK,aAAa;AAEzE,WAAO;AAAA,MACN;AAAA,MACA,MAAM,IAAI,MAAM,IAAI;AAAA,MACpB,QAAQ;AAAA,MACR,QAAQ,KAAK,MAAM;AAAA,MACnB,SAAS,CAAC;AAAA,IACX;AAAA,EACD;AAEA,SAAO;AACR;AAEO,SAAS,oBAAoB,EAAE,QAAQ,UAAU,CAAC,GAAG,UAAU,QAAQ,GAAwD;AACrI,QAAM,CAAC,YAAY,aAAa,QAAI,wBAAwB,IAAI;AAKhE,QAAM,CAAC,aAAa,cAAc,QAAI,wBAAwB,IAAI;AASlE,QAAM,CAAC,SAAS,UAAU,QAAI,wBAAkC,IAAI;AAEpE,QAAM,aAAa,cAAAC,QAAM,MAAM,EAAE,QAAQ,iBAAiB,EAAE;AAO5D,QAAM,CAAC,mBAAmB,oBAAoB,QAAI,wBAAS,KAAK;AAGhE,QAAM,CAAC,UAAU,WAAW,QAAI,wBAAS,KAAK;AAa9C,QAAM,CAAC,UAAU,WAAW,QAAI,wBAAS,KAAK;AAC9C,QAAM,eAAW,sBAA6B,IAAI;AASlD,QAAM,CAAC,WAAW,YAAY,QAAI,wBAA+B,IAAI;AACrE,QAAM,kBAAc,2BAAY,CAAC,SAA+B;AAC/D,aAAS,UAAU;AACnB,iBAAa,IAAI;AAAA,EAClB,GAAG,CAAC,CAAC;AAIL,QAAM,YAAQ,uBAAQ,MAAM;AAC3B,UAAM,MAAM,oBAAI,IAAY;AAC5B,eAAW,KAAK,OAAQ,YAAW,KAAK,EAAE,OAAQ,KAAI,IAAI,EAAE,IAAI;AAChE,WAAO,CAAC,GAAG,GAAG,EAAE,KAAK;AAAA,EACtB,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,YAAY,KAAK,IAAI,KAAK,WAAW,SAAS,SAAS;AAa7D,QAAM,aAAa,KAAK,MAAM,KAAK,IAAI,IAAI,KAAK,IAAI,KAAK,YAAY,CAAC,CAAC,CAAC;AACxE,QAAM,YAAY,aAAa,cAAc;AAO7C,gBAAAA,QAAM,gBAAgB,MAAM;AAC3B,UAAM,QAAQ;AACd,QAAI,CAAC,MAAO;AAKZ,UAAM,QAAQ,CAAC,UAAkB;AAGhC,UAAI,SAAS,EAAG;AAChB,YAAM,OAAO,KAAK,MAAM,KAAK;AAC7B,kBAAY,CAAC,YAAa,KAAK,IAAI,OAAO,OAAO,IAAI,IAAI,OAAO,OAAQ;AAAA,IACzE;AAEA,UAAM,MAAM,sBAAsB,EAAE,KAAK;AACzC,QAAI,OAAO,mBAAmB,YAAa;AAE3C,UAAM,WAAW,IAAI,eAAe,CAAC,YAAY;AAChD,YAAM,QAAQ,QAAQ,CAAC,GAAG,YAAY;AACtC,UAAI,UAAU,OAAW,OAAM,KAAK;AAAA,IACrC,CAAC;AACD,aAAS,QAAQ,KAAK;AACtB,WAAO,MAAM,SAAS,WAAW;AAAA,EAClC,GAAG,CAAC,SAAS,CAAC;AACd,QAAM,SAAS,UAAU,OAAO,SAAS,YAAY;AAErD,QAAM,QAAI,uBAAQ,MAAM;AACvB,QAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,eAAO,0BAAS,EACd,OAAO,CAAC,oBAAI,KAAK,GAAG,MAAM,CAAC,CAAC,YAAY,GAAG,oBAAI,KAAK,GAAG,MAAM,MAAM,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC,EAC5F,MAAM,CAAC,QAAQ,SAAS,SAAS,CAAC;AAAA,EACrC,GAAG,CAAC,OAAO,SAAS,CAAC;AAGrB,QAAM,cAAU;AAAA,IACf,CAAC,YAAoB;AACpB,YAAM,QAAQ,SAAS;AACvB,UAAI,CAAC,SAAS,MAAM,WAAW,EAAG,QAAO;AACzC,YAAM,MAAM,MAAM,sBAAsB;AACxC,aAAO,WAAW,SAAS,IAAI,MAAM,IAAI,OAAO,UAAU,WAAW,MAAM,MAAM;AAAA,IAClF;AAAA,IACA,CAAC,MAAM,QAAQ,WAAW,QAAQ;AAAA,EACnC;AAUA,QAAM,aAAS;AAAA,IACd,CAAC,MAAc,OAAe;AAC7B,UAAI,CAAC,QAAS;AAKd,UAAI,KAAK,OAAO,iBAAiB,GAAG;AAKnC,oBAAY,KAAK,IAAI;AACrB;AAAA,MACD;AACA,kBAAY,KAAK;AACjB,YAAM,QAAQ,MAAM,IAAI;AACxB,YAAM,MAAM,MAAM,EAAE;AACpB,UAAI,SAAS,IAAK,SAAQ,OAAO,GAAG;AAAA,IACrC;AAAA,IACA,CAAC,SAAS,KAAK;AAAA,EAChB;AAEA,QAAM,aAAS;AAAA,IACd,CAAC,UAA6C;AAC7C,YAAM,QAAQ,QAAQ,MAAM,OAAO;AACnC,UAAI,UAAU,KAAM;AACpB,2BAAqB,KAAK;AAC1B,oBAAc,KAAK;AAEnB,UAAI,gBAAgB,MAAM;AACzB,cAAM,OAAO,KAAK,IAAI,aAAa,KAAK;AACxC,cAAM,KAAK,KAAK,IAAI,aAAa,KAAK;AAItC,mBAAW,CAAC,YAAa,WAAW,QAAQ,CAAC,MAAM,QAAQ,QAAQ,CAAC,MAAM,KAAK,UAAU,CAAC,MAAM,EAAE,CAAE;AAAA,MACrG;AAAA,IACD;AAAA,IACA,CAAC,SAAS,WAAW;AAAA,EACtB;AAcA,QAAM,kBAAc,uBAAQ,MAAM;AACjC,QAAI,MAAM,WAAW,EAAG,QAAO,CAAC;AAChC,QAAI,MAAM,WAAW,EAAG,QAAO,CAAC,CAAC;AACjC,UAAM,aAAa,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,KAAK,MAAM,YAAY,EAAE,CAAC,CAAC;AACtE,UAAM,QAAQ,MAAM,SAAS,MAAM,aAAa;AAChD,UAAM,UAAU,MAAM,KAAK,EAAE,QAAQ,WAAW,GAAG,CAAC,GAAG,MAAM,KAAK,MAAM,IAAI,IAAI,CAAC;AAGjF,WAAO,CAAC,GAAG,IAAI,IAAI,OAAO,CAAC;AAAA,EAC5B,GAAG,CAAC,MAAM,QAAQ,SAAS,CAAC;AAE5B,MAAI,MAAM,SAAS,KAAK,OAAO,WAAW,KAAK,CAAC,EAAG,QAAO;AAE1D,QAAM,cAAc,eAAe,OAAO,MAAM,UAAU,IAAI;AAM9D,QAAM,UAAU,CAAC,QAAgB;AAChC,UAAM,OAAO,IAAI,OAAO,OAAO,CAAC,MAAM,EAAE,UAAU,IAAI;AACtD,QAAI,KAAK,WAAW,EAAG,QAAO,GAAG,IAAI,KAAK;AAC1C,UAAM,MAAM,KAAK,OAAO,CAAC,MAAM,MAAO,EAAE,QAAQ,KAAK,QAAQ,IAAI,MAAO,KAAK,CAAC,CAAE;AAChF,UAAM,QAAQ,KAAK,OAAO,CAAC,KAAK,MAAM,MAAM,EAAE,OAAO,CAAC;AAMtD,QAAI,IAAI,SAAS,UAAU;AAC1B,YAAM,SAAS,KAAK,OAAO,CAAC,MAAM,EAAE,UAAU,CAAC;AAC/C,UAAI,OAAO,WAAW,GAAG;AACxB,eAAO,GAAG,IAAI,KAAK,KAAK,IAAI,MAAM,4BAA4B,KAAK,MAAM;AAAA,MAC1E;AACA,aAAO,GAAG,IAAI,KAAK,KAAK,IAAI,MAAM,MAAM,YAAY,OAAO,IAAI,MAAM,QAAQ,CAAC,WACxE,OAAO,MAAM,OAAO,KAAK,MAAM,sBAAsB,YAAY,IAAI,OAAO,IAAI,MAAM,QAAQ,CAAC,OAC5F,UAAU,oBAAI,KAAK,GAAG,IAAI,IAAI,YAAY,CAAC,CAAC;AAAA,IACtD;AAEA,WAAO,GAAG,IAAI,KAAK,KAAK,IAAI,MAAM,MAAM,YAAY,OAAO,IAAI,MAAM,QAAQ,CAAC,yBAC7D,YAAY,IAAI,OAAO,IAAI,MAAM,QAAQ,CAAC,OAAO,UAAU,oBAAI,KAAK,GAAG,IAAI,IAAI,YAAY,CAAC,CAAC;AAAA,EAC/G;AAEA,QAAM,SAAS,OAAO,QAAQ,CAAC,QAAQ;AACtC,QAAI,CAAC,IAAI,UAAW,QAAO,CAAC;AAC5B,UAAM,OAAO,IAAI,IAAI,IAAI,UAAU,OAAO,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;AACvE,QAAI,MAAM;AACV,QAAI,SAAS;AACb,QAAI,QAAgD;AAMpD,UAAM,UAAU,IAAI,OAAO,OAAO,CAAC,MAAM,MAAM,KAAK,IAAI,MAAM,EAAE,SAAS,CAAC,GAAG,CAAC;AAC9E,UAAM,QAAQ,KAAK,IAAI,GAAG,UAAU,GAAG;AAEvC,eAAW,SAAS,IAAI,QAAQ;AAC/B,UAAI,MAAM,UAAU,KAAM;AAC1B,YAAM,MAAM,KAAK,IAAI,MAAM,IAAI;AAC/B,UAAI,OAAO,KAAM;AACjB,YAAM,aAAa,MAAM,QAAQ;AAIjC,UAAI,cAAc,EAAG,QAAO;AAAA,UACvB,WAAU,CAAC;AAChB,YAAM,QAAQ,MAAM,QAAQ,IAAI,IAAI,MAAM,MAAM,QAAQ;AACxD,UAAI,MAAM,SAAS,UAAU,CAAC,SAAS,QAAQ,MAAM,OAAQ,SAAQ,EAAE,MAAM,MAAM,MAAM,MAAM;AAAA,IAChG;AAUA,UAAM,YAAsB,CAAC;AAC7B,QAAI,SAAS,GAAG;AACf,gBAAU,KAAK,GAAG,IAAI,UAAU,MAAM,YAAY,YAAY,KAAK,MAAM,MAAM,GAAG,IAAI,MAAM,QAAQ,CAAC,SAC/F,IAAI,MAAM,YAAY,CAAC,SAAS,IAAI,MAAM,kFACjB;AAAA,IAChC;AACA,QAAI,OAAO,EAAG,QAAO;AAKrB,UAAM,gBAAgB,IAAI,OAAO,IAAI,CAAC,UAAU;AAC/C,UAAI,MAAM,UAAU,QAAQ,MAAM,SAAS,EAAG,QAAO;AACrD,YAAM,MAAM,KAAK,IAAI,MAAM,IAAI;AAC/B,aAAO,OAAO,OAAO,OAAO,MAAM,MAAM;AAAA,IACzC,CAAC;AACD,UAAM,WAAW;AAAA,MAChB;AAAA,MACA,IAAI,OAAO,IAAI,CAAC,MAAM,EAAE,IAAI;AAAA,MAC5B,IAAI,OAAO,IAAI,CAAC,MAAM,EAAE,KAAK;AAAA,IAC9B;AACA,QAAI,UAAU;AACb,gBAAU;AAAA,QACT,GAAG,IAAI,UAAU,MAAM,6BAA6B,cAAc,SAAS,QAAQ,CAAC,CAAC,OAChF,cAAc,SAAS,QAAQ,CAAC,CAAC,OAAO,UAAU,oBAAI,KAAK,GAAG,SAAS,KAAK,YAAY,CAAC,CAAC,MAC5F,SAAS,UACT,8BAA8B,SAAS,IAAI,4GAC3C,QAAQ,SAAS,IAAI;AAAA,MACzB;AAAA,IACD;AAUA,UAAM,QAAQ,GAAG,IAAI,UAAU,MAAM,WAAW,YAAY,KAAK,MAAM,GAAG,GAAG,IAAI,MAAM,QAAQ,CAAC,IAC1F,IAAI,MAAM,YAAY,CAAC;AAI7B,cAAU,KAAK,SAAS,MAAM,QAAQ,IACnC,GAAG,KAAK,2BAA2B,UAAU,oBAAI,KAAK,GAAG,MAAM,IAAI,YAAY,CAAC,CAAC,OAAO,cAAc,MAAM,OAAO,CAAC,CAAC,MACrH,GAAG,KAAK,GAAG;AACd,WAAO;AAAA,EACR,CAAC;AAED,QAAM,UAAU,GAAG,MAAM,MAAM,cAAc,UAAU,oBAAI,KAAK,GAAG,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC,OACnF,UAAU,oBAAI,KAAK,GAAG,MAAM,MAAM,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC,OAC9D,OAAO,IAAI,OAAO,EAAE,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,GAAG,KACtD,QAAQ,SAAS,IAAI,IAAI,QAAQ,MAAM,iBAAiB,QAAQ,WAAW,IAAI,KAAK,GAAG,aAAa;AAExG,SACC,8CAAC,kCAAM,OAAO,GAUZ;AAAA,WAAO,SAAS,KAChB,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,WAAU,QAAM,MACjD,uDAAC,kCAAM,OAAO,GACZ,iBAAO,IAAI,CAAC,aAAa,6CAAC,iCAAoB,MAAM,GAAI,sBAApB,QAA6B,CAAO,GAC1E,GACD;AAAA,IAGD,8CAAC,SAAI,OAAO,YACX;AAAA;AAAA,QAAC;AAAA;AAAA,UACA,KAAK;AAAA,UACL,SAAS,OAAO,QAAQ,IAAI,MAAM;AAAA,UAQlC,eAAe;AAAA,UAOf,gBAAgB,MAAM;AAAE,0BAAc,IAAI;AAAG,2BAAe,IAAI;AAAG,uBAAW,IAAI;AAAA,UAAE;AAAA,UACpF,eAAe,CAAC,UAAU;AACzB,gBAAI,CAAC,QAAS;AACd,wBAAY,KAAK;AACjB,kBAAM,QAAQ,QAAQ,MAAM,OAAO;AACnC,gBAAI,UAAU,KAAM;AACpB,kBAAM,eAAe;AAKrB,kBAAM,cAAc,kBAAkB,MAAM,SAAS;AACrD,2BAAe,KAAK;AACpB,uBAAW,CAAC,OAAO,KAAK,CAAC;AAAA,UAC1B;AAAA,UACA,aAAa,CAAC,UAAU;AACvB,gBAAI,CAAC,WAAW,gBAAgB,KAAM;AACtC,gBAAI,MAAM,cAAc,kBAAkB,MAAM,SAAS,GAAG;AAC3D,oBAAM,cAAc,sBAAsB,MAAM,SAAS;AAAA,YAC1D;AACA,kBAAM,QAAQ,QAAQ,MAAM,OAAO,KAAK,cAAc;AACtD,kBAAM,OAAO,KAAK,IAAI,aAAa,KAAK;AACxC,kBAAM,KAAK,KAAK,IAAI,aAAa,KAAK;AACtC,2BAAe,IAAI;AACnB,uBAAW,IAAI;AACf,mBAAO,MAAM,EAAE;AAAA,UAChB;AAAA,UACA,OAAO,EAAE,OAAO,QAAQ,QAAQ,SAAS,SAAS,QAAQ,UAAU,eAAe,UAAU;AAAA,UAC7F,UAAU;AAAA,UACV,MAAK;AAAA,UAKL,cAAY;AAAA,UACZ,SAAS,MAAM,cAAc,CAAC,YAAY,WAAW,MAAM,SAAS,CAAC;AAAA,UACrE,QAAQ,MAAM,cAAc,IAAI;AAAA,UAChC,WAAW,CAAC,UAAU;AAGrB,gBAAI,OAAsB;AAC1B,kBAAM,UAAU,cAAc,MAAM,SAAS;AAC7C,gBAAI,MAAM,QAAQ,aAAc,QAAO,KAAK,IAAI,UAAU,GAAG,MAAM,SAAS,CAAC;AAC7E,gBAAI,MAAM,QAAQ,YAAa,QAAO,KAAK,IAAI,UAAU,GAAG,CAAC;AAC7D,gBAAI,MAAM,QAAQ,OAAQ,QAAO;AACjC,gBAAI,MAAM,QAAQ,MAAO,QAAO,MAAM,SAAS;AAC/C,gBAAI,MAAM,QAAQ,UAAU;AAAE,4BAAc,IAAI;AAAG,yBAAW,IAAI;AAAG;AAAA,YAAO;AAC5E,gBAAI,MAAM,QAAQ,WAAW,WAAW,SAAS;AAChD,oBAAM,eAAe;AACrB,qBAAO,QAAQ,CAAC,GAAG,QAAQ,CAAC,CAAC;AAC7B,yBAAW,IAAI;AACf;AAAA,YACD;AACA,gBAAI,SAAS,KAAM;AACnB,kBAAM,eAAe;AACrB,iCAAqB,IAAI;AACzB,0BAAc,IAAI;AAIlB,gBAAI,MAAM,YAAY,SAAS;AAM9B,oBAAM,SAAS,eAAe;AAC9B,kBAAI,gBAAgB,KAAM,gBAAe,OAAO;AAChD,yBAAW,CAAC,KAAK,IAAI,QAAQ,IAAI,GAAG,KAAK,IAAI,QAAQ,IAAI,CAAC,CAAC;AAAA,YAC5D;AAAA,UACD;AAAA,UAOC;AAAA,wBAAY,IAAI,CAAC,UAAU;AAC3B,oBAAM,OAAO,MAAM,KAAK;AACxB,kBAAI,CAAC,KAAM,QAAO;AAClB,oBAAM,KAAK,EAAE,oBAAI,KAAK,GAAG,IAAI,YAAY,CAAC;AAC1C,kBAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO;AACjC,qBACC;AAAA,gBAAC;AAAA;AAAA,kBAEA,IAAI;AAAA,kBACJ,IAAI;AAAA,kBACJ,IAAI;AAAA,kBAIJ,IAAI,SAAS,cAAc;AAAA,kBAC3B,QAAQ,KAAK,YAAY;AAAA,kBACzB,aAAa;AAAA;AAAA,gBATR,QAAQ,IAAI;AAAA,cAUlB;AAAA,YAEF,CAAC;AAAA,YAEA,OAAO,IAAI,CAAC,KAAK,aAAa;AAG9B,oBAAM,SAAS,OAAO,SAAS,IAAI,QAAQ,IAAI,MAAM,IAAI,KAAK,CAAC;AAC/D,oBAAM,kBAAkB,IAAI,YACzB,OAAO,SAAS,IAAI,UAAU,QAAQ,IAAI,MAAM,IAAI,UAAU,KAAK,CAAC,IACpE;AACH,oBAAM,MAAM,UAAU,WAAW;AACjC,oBAAM,SAAS,MAAM,YAAY;AAMjC,oBAAM,UAAU,MAAM;AAEtB,oBAAM,SAAS,IAAI,OAAO,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,MAAmB,MAAM,IAAI;AAOnF,oBAAM,oBAAoB,IAAI,cAAc,CAAC,GAC3C,IAAI,CAAC,MAAM,EAAE,KAAK,EAClB,OAAO,CAAC,MAAmB,MAAM,IAAI;AAGvC,oBAAM,WAAO,gBAAAC,KAAM,CAAC,GAAG,QAAQ,GAAG,gBAAgB,CAAC,KAAK;AAMxD,oBAAM,SAAS,KAAK,IAAI,GAAG,GAAG,QAAQ,GAAG,gBAAgB;AACzD,oBAAM,IAAI,IAAI,aACX,6BAAY,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,CAAC,QAAQ,OAAO,CAAC,QACxD,6BAAY,EAAE,OAAO,CAAC,QAAQ,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,QAAQ,OAAO,CAAC;AAG3E,oBAAM,QAAQ,EAAE,CAAC;AAEjB,oBAAM,KAAK,CAAC,MAAmB,EAAE,oBAAI,KAAK,GAAG,EAAE,IAAI,YAAY,CAAC;AAChE,oBAAM,UAAU,CAAC,MAAmB,EAAE,UAAU;AAOhD,oBAAM,cAAU,gBAAAC,MAAoB,EAAE,QAAQ,OAAO,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,MAAM,EAAE,EAAE,KAAe,CAAC,EAAE,MAAM,2BAAW;AAQ7G,oBAAM,kBAAkB,IAAI,WAAW,UAAU,CAAC;AAClD,oBAAM,OAAO,IAAI,SAAS,WAAW,KAAK,QAAQ,IAAI,MAAM,KAAK;AAKjE,oBAAM,QAAQ,IAAI,cAAc,IAAI,WAAW,SAAS,IACrD,QAAQ,IAAI,WAAW,MAAM,GAAG,MAAM,MAAM,EAAE,IAAI,CAAC,OAAO,OAAO;AAAA,gBAClE,MAAM,MAAM,CAAC,KAAK,MAAM;AAAA,gBACxB,OAAO,MAAM;AAAA,cACd,EAAE,CAAC,KAAK,KACN;AAUH,oBAAM,QAAQ,YAAY,KAAK,IAAI,GAAG,MAAM,SAAS,CAAC;AACtD,oBAAM,YAAY,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,QAAQ,IAAI,QAAQ,IAAI,KAAK,CAAC;AAIxE,oBAAM,QAAQ,IAAI,SAAS,WACxB,IAAI,OAAO,OAAO,CAAC,MAAM,EAAE,UAAU,QAAS,EAAE,UAAqB,CAAC,IACtE,CAAC;AAWJ,oBAAM,SAAS,IAAI,SAAS,YAAY,SAAS,IAC9C,IAAI,OAAO,OAAO,CAAC,MAAM,EAAE,UAAU,CAAC,IACtC,CAAC;AAeJ,oBAAM,gBAAgB,IAAI,YAAY,QAAQ,eAAe,KAAK,KAAK;AAcvE,oBAAM,SAAS,GAAG,UAAU,QAAQ,QAAQ;AAE5C,qBACC,8CAAC,OACA;AAAA,6DAAC,UACA,uDAAC,cAAS,IAAI,QACb,uDAAC,UAAK,GAAG,QAAQ,GAAG,SAAS,OAAO,WAAW,QAAQ,KAAK,IAAI,GAAG,SAAS,OAAO,GAAG,GACvF,GACD;AAAA,gBACA,6CAAC,UAAK,IAAI,QAAQ,IAAI,SAAS,WAAW,IAAI,OAAO,IAAI,OAAO,QAAO,gBAAe,aAAa,GAAG,SAAS,MAAM;AAAA,gBAmBrH,6CAAC,UAAK,eAAY,IAAG,GAAG,SAAS,GAAG,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,IAAI,GAAG,MAAM,QAAQ;AAAA,gBAC1F,6CAAC,UAAK,GAAG,SAAS,IAAI,GAAG,MAAM,IAAI,UAAU,WAAW,MAAK,gBAAe,SAAS,MAAM,YAAY,KACrG,cAAI,OACN;AAAA,gBAIF,8CAAC,UAAK,GAAG,SAAS,WAAW,GAAG,MAAM,IAAI,YAAW,OAAM,UAAU,WAAW,MAAK,gBAAe,SAAS,MACzG;AAAA,sBAAI;AAAA,kBAAQ,IAAI,WAAW,KAAK;AAAA,mBAClC;AAAA,gBAMA,6CAAC,UAAK,GAAG,SAAS,GAAG,GAAG,EAAE,EAAE,OAAO,EAAE,CAAC,CAAW,IAAI,GAAG,YAAW,OAAM,UAAU,WAAW,MAAK,gBAAe,SAAS,KACzH,sBAAY,EAAE,OAAO,EAAE,CAAC,GAAa,IAAI,MAAM,QAAQ,GACzD;AAAA,gBAOF,6CAAC,UAAK,GAAG,SAAS,GAAG,GAAG,SAAS,GAAG,YAAW,OAAM,UAAU,WAAW,MAAK,gBAAe,SAAS,KACrG,sBAAY,EAAE,OAAO,EAAE,CAAC,GAAa,IAAI,MAAM,QAAQ,GACzD;AAAA,gBAQE,8CAAC,OAAE,UAAU,QAAQ,MAAM,KAI1B;AAAA,2BACA;AAAA,oBAAC;AAAA;AAAA,sBACA,GAAG;AAAA,sBACH,MAAK;AAAA,sBACL,QAAQ,KAAK,aAAa;AAAA,sBAC1B,aAAa;AAAA,sBACb,iBAAgB;AAAA;AAAA,kBACjB;AAAA,kBAwBA,iBACA;AAAA,oBAAC;AAAA;AAAA,sBACA,GAAG;AAAA,sBACH,MAAK;AAAA,sBACL,QAAQ,KAAK,aAAa;AAAA,sBAC1B,aAAa;AAAA,sBACb,iBAAgB;AAAA;AAAA,kBACjB;AAAA,kBAKA,OAAO,IAAI,CAAC,MAAM;AAClB,0BAAM,KAAK,GAAG,CAAC;AACf,wBAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO;AACjC,2BACC;AAAA,sBAAC;AAAA;AAAA,wBAEA,GAAG,KAAK,IAAI,QAAQ,KAAK,IAAI,SAAS,YAAY,WAAW,KAAK,YAAY,CAAC,CAAC;AAAA,wBAIhF,GAAG,QAAQ;AAAA,wBACX,OAAO;AAAA,wBACP,QAAQ;AAAA,wBACR,MAAM;AAAA;AAAA,sBARD,QAAQ,EAAE,IAAI;AAAA,oBASpB;AAAA,kBAEF,CAAC;AAAA,kBAEA,MAAM,IAAI,CAAC,MAAM;AACjB,0BAAM,KAAK,GAAG,CAAC;AAEf,0BAAM,QAAQ,EAAE,EAAE,KAAe;AACjC,wBAAI,CAAC,OAAO,SAAS,EAAE,KAAK,CAAC,OAAO,SAAS,KAAK,EAAG,QAAO;AAC5D,2BACC;AAAA,sBAAC;AAAA;AAAA,wBAKA,GAAG,KAAK,IAAI,QAAQ,KAAK,IAAI,SAAS,YAAY,WAAW,KAAK,YAAY,CAAC,CAAC;AAAA,wBAChF,GAAG,KAAK,IAAI,OAAO,KAAK;AAAA,wBACxB,OAAO;AAAA,wBAIP,QAAQ,KAAK,IAAI,KAAK,KAAK,IAAI,QAAQ,KAAK,CAAC;AAAA,wBAC7C,MAAM;AAAA;AAAA,sBAXD,EAAE;AAAA,oBAYR;AAAA,kBAEF,CAAC;AAAA,kBAEA,QAAQ;AAAA,oBAAC;AAAA;AAAA,sBACT,GAAG;AAAA,sBACH,MAAK;AAAA,sBACL;AAAA,sBACA,aAAa;AAAA,sBAOb,iBAAiB,IAAI,WAAW,SAAY;AAAA;AAAA,kBAC7C;AAAA,mBACA;AAAA,mBAhKO,IAAI,GAiKZ;AAAA,YAEF,CAAC;AAAA,YAIA,QAAQ,IAAI,CAAC,WAAW;AACxB,oBAAM,KAAK,EAAE,oBAAI,KAAK,GAAG,OAAO,IAAI,YAAY,CAAC;AACjD,kBAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO;AACjC,qBACC,8CAAC,OACA;AAAA;AAAA,kBAAC;AAAA;AAAA,oBACA,IAAI;AAAA,oBACJ,IAAI;AAAA,oBACJ,IAAI,UAAU;AAAA,oBACd,IAAI,UAAU,OAAO,SAAS,YAAY;AAAA,oBAC1C,QAAO;AAAA,oBACP,aAAa;AAAA,oBACb,iBAAgB;AAAA,oBAChB,SAAS;AAAA;AAAA,gBACV;AAAA,gBACA,6CAAC,YAAO,IAAI,IAAI,IAAI,UAAU,GAAG,GAAG,GAAG,MAAK,gBAAe,SAAS,KAAK;AAAA,mBAXlE,GAAG,OAAO,IAAI,IAAI,OAAO,KAAK,EAYtC;AAAA,YAEF,CAAC;AAAA,YASA,WAAW,QAAQ,CAAC,IAAI,QAAQ,CAAC,KAAK,QAAQ,CAAC,IAAI,MAAM,UACzD,8CAAC,OAAE,eAAY,QAKd;AAAA;AAAA,gBAAC;AAAA;AAAA,kBACA,GAAG;AAAA,kBACH,GAAG,UAAU;AAAA,kBACb,OAAO,KAAK,IAAI,GAAG,EAAE,oBAAI,KAAK,GAAG,MAAM,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC,IAAI,MAAM;AAAA,kBACzE,QAAQ,OAAO,SAAS,YAAY;AAAA,kBACpC,MAAK;AAAA,kBACL,SAAS;AAAA;AAAA,cACV;AAAA,cACA;AAAA,gBAAC;AAAA;AAAA,kBACA,GAAG,EAAE,oBAAI,KAAK,GAAG,MAAM,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC;AAAA,kBAC/C,GAAG,UAAU;AAAA,kBACb,OAAO,KAAK,IAAI,GAAG,SAAS,YAAY,EAAE,oBAAI,KAAK,GAAG,MAAM,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;AAAA,kBACrF,QAAQ,OAAO,SAAS,YAAY;AAAA,kBACpC,MAAK;AAAA,kBACL,SAAS;AAAA;AAAA,cACV;AAAA,eACD;AAAA,YAGA,eACA;AAAA,cAAC;AAAA;AAAA,gBACA,IAAI,EAAE,oBAAI,KAAK,GAAG,WAAW,YAAY,CAAC;AAAA,gBAC1C,IAAI,EAAE,oBAAI,KAAK,GAAG,WAAW,YAAY,CAAC;AAAA,gBAC1C,IAAI,UAAU;AAAA,gBACd,IAAI,UAAU,OAAO,SAAS,YAAY;AAAA,gBAC1C,QAAO;AAAA,gBACP,aAAa;AAAA,gBACb,SAAS;AAAA;AAAA,YACV;AAAA,YAQA,YAAY,IAAI,CAAC,OAAO,aAAa;AACrC,oBAAM,OAAO,MAAM,KAAK;AACxB,kBAAI,CAAC,KAAM,QAAO;AAClB,qBACC;AAAA,gBAAC;AAAA;AAAA,kBAEA,GAAG,EAAE,oBAAI,KAAK,GAAG,IAAI,YAAY,CAAC;AAAA,kBAClC,GAAG,SAAS;AAAA,kBACZ,YAAY,aAAa,IAAI,UAAU,aAAa,YAAY,SAAS,IAAI,QAAQ;AAAA,kBACrF,UAAU;AAAA,kBACV,MAAK;AAAA,kBACL,SAAS;AAAA,kBAER,oBAAU,oBAAI,KAAK,GAAG,IAAI,YAAY,CAAC;AAAA;AAAA,gBARnC;AAAA,cASN;AAAA,YAEF,CAAC;AAAA;AAAA;AAAA,MACF;AAAA,MAKC,eACA;AAAA,QAAC;AAAA;AAAA,UACA,GAAG,EAAE,oBAAI,KAAK,GAAG,WAAW,YAAY,CAAC;AAAA,UACzC,WAAW;AAAA,UACX,MAAM,UAAU,oBAAI,KAAK,GAAG,WAAW,YAAY,CAAC;AAAA,UACpD,MAAM,OAAO,IAAI,CAAC,QAAQ;AACzB,kBAAM,QAAQ,IAAI,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS,WAAW;AAC3D,kBAAM,OAAO,IAAI,WAAW,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS,WAAW;AACrE,mBAAO;AAAA,cACN,KAAK,IAAI;AAAA,cACT,OAAO,IAAI;AAAA,cACX,OAAO,OAAO,SAAS,IAAI,QAAQ,IAAI,MAAM,IAAI,KAAK,CAAC;AAAA;AAAA;AAAA,cAGvD,OAAO,SAAS,MAAM,UAAU,OAAO,YAAY,MAAM,OAAO,IAAI,MAAM,QAAQ,IAAI;AAAA,cACtF,MAAM,QAAQ,KAAK,UAAU,OAC1B,GAAG,IAAI,WAAW,MAAM,IAAI,YAAY,KAAK,OAAO,IAAI,MAAM,QAAQ,CAAC,KACvE;AAAA,YACJ;AAAA,UACD,CAAC;AAAA,UACD,QAAQ,QAAQ,OAAO,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK;AAAA;AAAA,MACzE;AAAA,OAEF;AAAA,IAIA,8CAAC,SAAI,OAAO,YAAY,aAAW,oBAAoB,WAAW,OACjE;AAAA,mDAAC,iCAAK,MAAM,GAAG,QAAO,UAGpB,qBAAW,QAAQ,CAAC,IAAI,QAAQ,CAAC,KAAK,QAAQ,CAAC,IAAI,MAAM,SACvD,GAAG,UAAU,oBAAI,KAAK,GAAG,MAAM,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,WAAM,UAAU,oBAAI,KAAK,GAAG,MAAM,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,SAAM,QAAQ,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,UACpJ,cACC,UAAU,oBAAI,KAAK,GAAG,WAAW,YAAY,CAAC,IAM9C,IACL;AAAA,MAMC,eAAe,OAAO,IAAI,CAAC,QAAQ;AACnC,cAAM,QAAQ,IAAI,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS,WAAW;AAC3D,cAAM,OAAO,IAAI,WAAW,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS,WAAW;AACrE,eACC,8CAAC,iCAAmB,MAAM,GAAG,OAAK,MAChC;AAAA,cAAI;AAAA,UAAM;AAAA,UAAE;AAAA,UACZ,SAAS,MAAM,UAAU,OAAO,YAAY,MAAM,OAAO,IAAI,MAAM,QAAQ,IAAI;AAAA,UAC/E,QAAQ,KAAK,UAAU,QACvB,8EAAE;AAAA;AAAA,YAAI,IAAI,WAAW;AAAA,YAAO;AAAA,YAAM,YAAY,KAAK,OAAO,IAAI,MAAM,QAAQ;AAAA,aAAE;AAAA,aAJrE,IAAI,GAMf;AAAA,MAEF,CAAC;AAAA,MACA,eAAe,QAAQ,OAAO,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,IAAI,CAAC,MAClE,8CAAC,iCAAmB,MAAM,GAAG;AAAA;AAAA,QAAG,EAAE;AAAA,WAAvB,EAAE,KAA2B,CACxC;AAAA,OACF;AAAA,IAcA,8CAAC,SAAI,OAAO,WACV;AAAA,aAAO,KAAK,CAAC,QAAQ,IAAI,SAAS,KAClC,6EAIC;AAAA,QAAC;AAAA;AAAA,UACA,QAAQ,6CAAC,UAAK,OAAO,WAAW,gBAAgB,MAAM,CAAC,GAAG;AAAA,UAC1D,OAAO,GAAG,eAAe,MAAM,CAAC;AAAA;AAAA,MACjC,GAED;AAAA,MAEA,OAAO,KAAK,CAAC,SAAS,IAAI,YAAY,UAAU,KAAK,CAAC,KACtD;AAAA,QAAC;AAAA;AAAA,UACA,QAAQ,6CAAC,UAAK,OAAO,WAAW,KAAK,aAAa,CAAC,GAAG;AAAA,UACtD,OAAM;AAAA;AAAA,MACP;AAAA,MAEA,QAAQ,SAAS,KACjB;AAAA,QAAC;AAAA;AAAA,UACA,QAAQ,6CAAC,UAAK,OAAO,cAAc,uDAAC,UAAK,OAAO,iBAAiB,GAAE;AAAA,UACnE,OAAM;AAAA;AAAA,MACP;AAAA,MAEA;AAAA;AAAA;AAAA,MAIA,6CAAC,iCAAK,MAAM,GAAG,OAAO,CAAC,UAAU,aAAU,UACzC,qBACE,oEAA+D,cAAc,gCAC7E,sIACJ;AAAA,OAEF;AAAA,KACD;AAEF;AASA,SAAS,UAAU,EAAE,QAAQ,MAAM,GAAsE;AACxG,SACC,8CAAC,UAAK,OAAO,WACZ;AAAA,iDAAC,UAAK,eAAY,QAAQ,kBAAO;AAAA,IACjC,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,iBAAM;AAAA,KAC7B;AAEF;AAGA,IAAM,YAAiC,EAAE,SAAS,eAAe,YAAY,UAAU,KAAK,EAAE;AAO9F,SAAS,WAAW,QAAqC;AACxD,SAAO;AAAA,IACN,SAAS;AAAA,IACT,OAAO;AAAA,IACP,QAAQ;AAAA,IACR,WAAW,cAAc,MAAM;AAAA,EAChC;AACD;AA0BA,IAAM,eAAoC;AAAA,EACzC,SAAS;AAAA,EACT,UAAU;AAAA,EACV,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,YAAY;AAAA;AAAA;AAAA;AAAA,EAIZ,SAAS;AACV;AAUA,IAAM,kBAAuC;AAAA,EAC5C,UAAU;AAAA,EACV,KAAK;AAAA,EACL,MAAM;AAAA,EACN,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,YAAY;AAAA,EACZ,SAAS;AACV;AAOA,SAAS,aAAa,QAAsC;AAC3D,QAAM,MAAM,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS;AAC1C,SAAO,SAAS,KAAK,WAAW,UAAU,OAAO,KAAK,QAAQ,SAAS,KAAK,WAAW,KAAK;AAC7F;AAOA,SAAS,gBAAgB,QAAmC;AAC3D,SAAO,OAAO,aAAa,MAAM,CAAC;AACnC;AAUA,SAAS,eAAe,QAAmC;AAC1D,SAAO,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS,GAAG,WAAW,SAAS;AAC7D;AAGA,IAAM,aAAkC,EAAE,OAAO,QAAQ,UAAU,UAAU,UAAU,WAAW;AAWlG,IAAM,aAAkC;AAAA,EACvC,SAAS;AAAA,EACT,KAAK,MAAM;AAAA,EACX,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,WAAW;AACZ;AAIA,IAAM,YAAiC;AAAA,EACtC,SAAS;AAAA,EACT,KAAK,MAAM;AAAA,EACX,UAAU;AAAA,EACV,YAAY;AACb;;;ACv9CO,IAAM,oBAAoB;AAc1B,SAAS,WAAW,QAAkD;AAC5E,MAAI,OAAO,SAAS,IAAI,kBAAmB,QAAO;AAGlD,QAAM,QAA8B,CAAC;AACrC,WAAS,MAAM,OAAO,QAAQ,MAAM,KAAK,GAAG,OAAO,GAAG;AACrD,UAAM,QAAQ,OAAO,MAAM,MAAM,GAAG,GAAG;AAEvC,UAAM,KAAK,MAAM,KAAK,CAAC,MAAM,EAAE,UAAU,IAAI,IAAI,OAAO,MAAM,OAAO,CAAC,KAAK,MAAM,MAAO,EAAE,OAAkB,CAAC,CAAC;AAAA,EAC/G;AAEA,QAAM,SAAS,MAAM,CAAC;AACtB,MAAI,WAAW,QAAQ,WAAW,OAAW,QAAO;AAEpD,QAAM,aAAa,MAAM,OAAO,CAAC,MAAmB,MAAM,IAAI;AAC9D,MAAI,WAAW,SAAS,kBAAmB,QAAO;AAIlD,QAAM,SAAS,WAAW,OAAO,CAAC,MAAM,IAAI,MAAM,EAAE;AAEpD,SAAO,EAAE,MAAM,SAAS,GAAG,IAAI,WAAW,QAAQ,OAAO,OAAO;AACjE;AAUO,SAAS,aAAa,MAA0B;AACtD,QAAM,UAAU,CAAC,MAAsB;AACtC,UAAM,OAAO,IAAI;AACjB,QAAI,QAAQ,MAAM,QAAQ,GAAI,QAAO,GAAG,CAAC;AACzC,WAAO,GAAG,CAAC,GAAG,CAAC,MAAM,MAAM,MAAM,IAAI,EAAE,IAAI,EAAE,KAAK,IAAI;AAAA,EACvD;AACA,MAAI,KAAK,SAAS,EAAG,QAAO,oBAAoB,KAAK,EAAE;AACvD,MAAI,KAAK,SAAS,KAAK,GAAI,QAAO,sBAAsB,KAAK,EAAE;AAC/D,SAAO,GAAG,QAAQ,KAAK,IAAI,CAAC,qBAAqB,KAAK,EAAE;AACzD;;;ACkDO,SAAS,iBAAiB,UAA0D;AAC1F,QAAM,IAAI,SAAS;AACnB,MAAI,CAAC,OAAO,SAAS,CAAC,KAAK,KAAK,EAAG,QAAO,EAAE,KAAK,SAAS,MAAM,MAAM,SAAS,KAAK;AAWpF,QAAM,IAAI,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,SAAS,IAAI,CAAC;AAChD,QAAM,QAAQ,IAAI,KAAK,KAAK,KAAK,IAAI,KAAK,IAAI,IAAI,IAAI,IAAI,CAAC;AAC3D,SAAO,EAAE,KAAK,KAAK,IAAI,GAAG,SAAS,OAAO,KAAK,GAAG,MAAM,SAAS,OAAO,MAAM;AAC/E;;;AH0BG,IAAAC,sBAAA;AA3IH,SAAS,gBAAgB,QAAgD;AACxE,MAAI,CAAC,UAAU,OAAO,WAAW,cAAe,QAAO;AACvD,SAAO,OAAO,SAAS,OAAO,KAAK,IAAI,OAAO,QAAQ;AACvD;AAiBA,SAAS,SAAS,QAAiC,QAA6B;AAC/E,MAAI,OAAQ,QAAO;AACnB,SAAO,EAAE,QAAQ,eAAe,QAAQ,kBAAkB,OAAO;AAClE;AAMA,IAAM,cAAc;AAYpB,SAAS,aAAa,OAAiD;AACtE,SAAO,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,IAAI,QAAQ;AACtE;AAGA,SAAS,kBAAkB,KAAwB;AAClD,SAAO,CAAC,IAAI,QAAQ,IAAI,SAAS,IAAI,UAAU,IAAI,IAAI,YAAY,EAAE,EAAE,KAAK,UAAU;AACvF;AAGA,IAAM,YAAiC;AAAA,EACtC,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,KAAK,MAAM;AAAA,EACX,UAAU;AACX;AAMA,IAAM,aAAkC;AAAA,EACvC,OAAO;AAAA,EACP,gBAAgB;AAAA,EAChB,qBAAqB;AAAA,EACrB,yBAAyB;AAC1B;AASA,SAAS,eAAe,QAAyB;AAChD,SAAO,+BAA+B,KAAK,MAAM;AAClD;AAUA,IAAM,WAAgC;AAAA,EACrC,SAAS;AAAA,EACT,qBAAqB;AAAA,EACrB,KAAK,MAAM;AACZ;AAWO,SAAS,MAAM,KAAqF;AAC1G,MAAI,IAAI,oBAAoB,QAAQ,IAAI,iBAAiB,KAAM,QAAO;AACtE,SAAO,IAAI,kBAAkB,IAAI;AAClC;AAWO,SAAS,WAAW,KAAqF;AAC/G,MAAI,IAAI,oBAAoB,QAAQ,IAAI,iBAAiB,QAAQ,IAAI,mBAAmB,EAAG,QAAO;AAClG,SAAO,IAAI,eAAe,IAAI;AAC/B;AAWO,SAAS,cAAc,EAAE,MAAM,UAAU,QAAQ,GAKjC;AACtB,SACC,8CAAC,SAAI,OAAO,YACX;AAAA,iDAAC,WAAQ,MAAY,UAAoB;AAAA,IAEzC,8CAAC,SAAI,OAAO,UACX;AAAA,mDAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YACX;AAAA,qDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,qBAAO;AAAA,QAC7B,8CAAC,SAAI,OAAO,WAMX;AAAA;AAAA,YAAC;AAAA;AAAA,cACA,QAAQ,SAAS,KAAK,SAAS,WAAW;AAAA,cAC1C,OAAM;AAAA,cACN,MAAK;AAAA,cACL,UAAU,KAAK,YAAY;AAAA;AAAA,UAC5B;AAAA,UACA,6CAAC,SAAM,SAAS,gBAAgB,KAAK,OAAO,GAAG,UAAU,gBAAgB,UAAU,OAAO,GAAG;AAAA,WAC9F;AAAA,QAiBC,wBAAwB,IAAI,KAAK,SAAS,KAAK,aAAa,CAAC,MAAM,EAAE,OAAO,KAC5E,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,mBAAS,KAAK,aAAa,CAAC,MAAM,EAAE,OAAO,GAAE;AAAA,SAErE,GACD;AAAA,MACA,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YACX;AAAA,qDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,oBAAM;AAAA,QAC5B,8CAAC,SAAI,OAAO,WACX;AAAA,uDAAC,gBAAa,QAAQ,SAAS,KAAK,QAAQ,WAAW,GAAG,OAAM,UAAS;AAAA,UACzE,6CAAC,SAAM,SAAS,gBAAgB,KAAK,MAAM,GAAG,UAAU,gBAAgB,UAAU,MAAM,GAAG;AAAA,WAC5F;AAAA,QACC,SAAS,KAAK,aAAa,CAAC,MAAM,EAAE,MAAM,KAC1C,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,mBAAS,KAAK,aAAa,CAAC,MAAM,EAAE,MAAM,GAAE;AAAA,SAEpE,GACD;AAAA,MACA,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YACX;AAAA,qDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,qBAAO;AAAA,QAI7B,8CAAC,SAAI,OAAO,WACX;AAAA,uDAAC,gBAAa,QAAQ,SAAS,KAAK,iBAAiB,WAAW,GAAG,OAAM,aAAY;AAAA,UACrF,6CAAC,SAAM,SAAS,gBAAgB,KAAK,eAAe,GAAG,UAAU,gBAAgB,UAAU,eAAe,GAAG;AAAA,WAC9G;AAAA,QACC,KAAK,mBAAmB,KAAK,gBAAgB,WAAW,iBACxD,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,wDAAqC;AAAA,SAE5D,GACD;AAAA,MACA,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YAMX;AAAA,qDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,iCAAmB;AAAA,QACzC,8CAAC,SAAI,OAAO,WACX;AAAA,uDAAC,gBAAa,QAAQ,SAAS,KAAK,UAAU,WAAW,GAAG,OAAM,wBAAuB;AAAA,WAMvF,MAAM;AAIP,kBAAM,SAAS,gBAAgB,KAAK,cAAc;AAClD,gBAAI,WAAW,MAAM;AAepB,oBAAM,SAAS,KAAK,gBAAgB,WAAW,gBAC5C,KAAK,eAAe,SACpB;AACH,qBAAO,SAAS,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,kBAAO,IAAU;AAAA,YACxD;AACA,mBACC,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAClB;AAAA,uBAAS,IAAI,MAAM;AAAA,cAAI,YAAY,MAAM;AAAA,cAAE;AAAA,eAC7C;AAAA,UAEF,GAAG;AAAA,WACJ;AAAA,QACC,KAAK,YAAY,KAAK,SAAS,WAAW,iBAC1C,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,mIAGrB;AAAA,SAEF,GACD;AAAA,OACD;AAAA,IAOA,8CAAC,SAAI,OAAO,UACX;AAAA,mDAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YACX;AAAA,qDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,2BAAa;AAAA,QAGnC;AAAA,UAAC;AAAA;AAAA,YACA,OAAO,gBAAgB,kBAAkB,IAAI,CAAC;AAAA,YAC9C,QAAQ,CAAC,MAAM,YAAY,GAAG,KAAK,YAAY,IAAI;AAAA,YACnD,OAAO;AAAA,cACN,EAAE,OAAO,SAAS,OAAO,gBAAgB,KAAK,OAAO,GAAG,QAAQ,CAAC,MAAM,YAAY,GAAG,KAAK,YAAY,IAAI,EAAE;AAAA,cAC7G,EAAE,OAAO,yBAAyB,OAAO,aAAa,KAAK,eAAe,EAAE;AAAA,YAC7E;AAAA,YACA,aAAa,6CAAC,gBAAa,QAAQ,kBAAkB,IAAI,GAAG,OAAM,uBAAsB;AAAA;AAAA,QACzF;AAAA,QACC,kBAAkB,IAAI,EAAE,WAAW,aAAc,kBAAkB,IAAI,EAAwB,QAC/F,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAG,4BAAkB,IAAI,EAAwB,MAAK;AAAA,QAE1E,kBAAkB,IAAI,EAAE,WAAW,QACnC,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAG2C,yCAE/D;AAAA,SAEF,GACD;AAAA,MAEA,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YACX;AAAA,qDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,gCAAkB;AAAA,QASxC;AAAA,UAAC;AAAA;AAAA,YACA,OAAO,gBAAgB,iBAAiB,IAAI,CAAC;AAAA,YAC7C,QAAQ,CAAC,MAAM,YAAY,CAAC;AAAA,YAC5B,OAAO;AAAA,cACN,EAAE,OAAO,YAAY,OAAO,gBAAgB,KAAK,cAAc,EAAE;AAAA,cACjE,EAAE,OAAO,UAAU,OAAO,gBAAgB,KAAK,MAAM,EAAE;AAAA,YACxD;AAAA,YACA,aAAa,6CAAC,gBAAa,QAAQ,iBAAiB,IAAI,GAAG,OAAM,sBAAqB;AAAA;AAAA,QACvF;AAAA,QACC,KAAK,kBAAkB,KAAK,eAAe,WAAW,iBACtD,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,mGAErB;AAAA,SAEF,GACD;AAAA,OACD;AAAA,KAEE,KAAK,aAAa,UAAU,MAAM,KACnC,8CAAC,SAAI,OAAO,YACX;AAAA,mDAAC,gBAAa,OAAM,gCAA+B;AAAA,MAInD,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,wGAGrB;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACA;AAAA,UACA,UAAU,KAAK,YAAY;AAAA,UAC3B,SAAS,KAAK,kBAAkB,CAAC;AAAA,UACjC,QAAQ;AAAA,YACP;AAAA;AAAA;AAAA;AAAA;AAAA,cAKC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,QAAQ;AAAA,cACR,UAAU;AAAA,cACV,MAAM;AAAA,cACN,SAAS,KAAK,eAAe,CAAC,GAAG,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,EAAE,gBAAgB,EAAE;AAAA;AAAA,cAExF,YAAY,UAAU,aAAa,SAChC,SAAS,YAAY,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,EAAE,gBAAgB,EAAE,IAC5E;AAAA,cACH,WAAW;AAAA,gBACV,OAAO;AAAA,gBACP,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA,gBAKR,SAAS,KAAK,eAAe,CAAC,GAAG,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,EAAE,aAAa,EAAE;AAAA,cACtF;AAAA,YACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,YAkBA,IAAK,KAAK,eAAe,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,oBAAoB,QAAQ,EAAE,iBAAiB,IAAI,IAC3F,CAAC;AAAA,cACF,KAAK;AAAA,cACL,OAAO;AAAA,cACP,QAAQ;AAAA,cACR,UAAU;AAAA,cACV,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAYN,QAAQ,CAAC,GAAG,KAAK,IAAI,GAAG,IAAI,KAAK,eAAe,CAAC,GAC/C,IAAI,CAAC,MAAO,EAAE,oBAAoB,QAAQ,EAAE,iBAAiB,QAAQ,EAAE,kBAAkB,IACvF,EAAE,eAAe,EAAE,kBACnB,CAAE,CAAC,CAAC;AAAA,cACR,SAAS,KAAK,eAAe,CAAC,GAAG,IAAI,CAAC,OAAO;AAAA,gBAC5C,MAAM,EAAE;AAAA,gBACR,OAAO,EAAE,oBAAoB,QAAQ,EAAE,iBAAiB,QAAQ,EAAE,kBAAkB,IACjF,EAAE,eAAe,EAAE,kBACnB;AAAA,cACJ,EAAE;AAAA;AAAA;AAAA,cAGF,YAAY,UAAU,aAAa,SAChC,SAAS,YAAY,IAAI,CAAC,OAAO;AAAA,gBAClC,MAAM,EAAE;AAAA,gBACR,OAAO,EAAE,oBAAoB,QAAQ,EAAE,iBAAiB,QAAQ,EAAE,kBAAkB,IACjF,EAAE,eAAe,EAAE,kBACnB;AAAA,cACJ,EAAE,IACA;AAAA,YACJ,CAAC,IACC,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,YAeJ;AAAA,cACC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,QAAQ;AAAA,cACR,UAAU;AAAA,cACV,MAAM;AAAA;AAAA,cAEN,MAAM;AAAA,cACN,SAAS,KAAK,eAAe,CAAC,GAAG,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,EAAE,OAAO,EAAE;AAAA,YAChF;AAAA,YACA,GAAI,wBAAwB,IAAI,IAC7B,CAAC;AAAA,cACF,KAAK;AAAA,cACL,OAAO;AAAA,cACP,QAAQ;AAAA,cACR,UAAU;AAAA,cACV,MAAM;AAAA,cACN,MAAM;AAAA,cACN,SAAS,KAAK,eAAe,CAAC,GAAG,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,EAAE,QAAQ,EAAE;AAAA,YACjF,CAAC,IACC,CAAC;AAAA,UACL;AAAA;AAAA,MACD;AAAA,MAEC,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA,MAML,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,oIAGrB;AAAA,MAGD;AAAA,QAAC;AAAA;AAAA,UAOA,OAAM;AAAA,UACN,MAAM,KAAK,eAAe,CAAC;AAAA,UAC3B,QAAQ,CAAC,MAAM,EAAE;AAAA,UACjB,YAAW;AAAA,UAGX,UAAU,CAAC,MAAM,EAAE;AAAA,UACnB,SAAS;AAAA,YACR,EAAE,KAAK,QAAQ,OAAO,QAAQ,WAAW,CAAC,MAAM,EAAE,MAAM,QAAQ,CAAC,MAAM,6CAAC,iCAAK,MAAM,GAAI,YAAE,MAAK,EAAQ;AAAA,YACtG;AAAA,cACC,KAAK;AAAA,cAAU,OAAO;AAAA,cAAa,SAAS;AAAA,cAC5C,WAAW,CAAC,MAAM,EAAE;AAAA,cACpB,QAAQ,CAAC,MAAM,6CAAC,iCAAK,MAAM,GAAI,YAAE,oBAAoB,OAAO,WAAM,YAAY,EAAE,eAAe,GAAE;AAAA,YAClG;AAAA,YACA;AAAA,cACC,KAAK;AAAA,cAAO,OAAO;AAAA,cAAe,SAAS;AAAA,cAC3C,WAAW,CAAC,MAAM,EAAE;AAAA,cACpB,QAAQ,CAAC,MAAM,6CAAC,iCAAK,MAAM,GAAI,YAAE,iBAAiB,OAAO,WAAM,YAAY,EAAE,YAAY,GAAE;AAAA,YAC5F;AAAA,YACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAWC,KAAK;AAAA,cAAU,OAAO;AAAA,cAAiB,SAAS;AAAA,cAChD,WAAW,CAAC,MAAM,MAAM,CAAC;AAAA,cACzB,QAAQ,CAAC,MAAM;AACd,sBAAM,MAAM,MAAM,CAAC;AACnB,uBAAO,6CAAC,iCAAK,MAAM,GAAI,kBAAQ,OAAO,WAAM,YAAY,GAAG,GAAE;AAAA,cAC9D;AAAA,YACD;AAAA,YACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAOC,KAAK;AAAA,cAAY,OAAO;AAAA,cAAiB,SAAS;AAAA,cAClD,WAAW,CAAC,MAAM,WAAW,CAAC;AAAA;AAAA;AAAA;AAAA,cAI9B,aAAa,CAAC,MAAM;AACnB,sBAAM,WAAW,WAAW,CAAC;AAC7B,uBAAO,aAAa,OAAO,OAAO,cAAc,UAAU,CAAC;AAAA,cAC5D;AAAA,cACA,QAAQ,CAAC,MAAM;AACd,sBAAM,WAAW,WAAW,CAAC;AAC7B,uBAAO,6CAAC,iCAAK,MAAM,GAAI,uBAAa,OAAO,WAAM,cAAc,UAAU,CAAC,GAAE;AAAA,cAC7E;AAAA,YACD;AAAA,YACA;AAAA,cACC,KAAK;AAAA,cAAU,OAAO;AAAA,cAAU,SAAS;AAAA,cACzC,WAAW,CAAC,MAAM,EAAE;AAAA,cACpB,QAAQ,CAAC,MAAM,6CAAC,iCAAK,MAAM,GAAI,YAAE,WAAW,OAAO,WAAM,YAAY,EAAE,MAAM,GAAE;AAAA,YAChF;AAAA,YACA;AAAA,cACC,KAAK;AAAA,cAAW,OAAO;AAAA,cAAW,SAAS;AAAA,cAC3C,WAAW,CAAC,MAAM,EAAE;AAAA,cACpB,aAAa,CAAC,MAAO,EAAE,YAAY,OAAO,OAAO,YAAY,EAAE,SAAS,KAAK,YAAY,IAAI;AAAA,cAC7F,QAAQ,CAAC,MAAM,6CAAC,iCAAK,MAAM,GAAI,YAAE,YAAY,OAAO,WAAM,YAAY,EAAE,SAAS,KAAK,YAAY,IAAI,GAAE;AAAA,YACzG;AAAA,UACD;AAAA;AAAA,MACD;AAAA,OACD;AAAA,KAwBC,KAAK,WAAW,UAAU,KAAK,KAChC;AAAA,MAAC;AAAA;AAAA,QACA,OAAM;AAAA,QACN,UAAS;AAAA,QAET;AAAA,uDAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,uLAGrB;AAAA,UAIA,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC;AAAA;AAAA,YAC4C;AAAA,YAAiB;AAAA,aAIlF;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACA,SAAQ;AAAA,cACR,aAAY;AAAA,cACZ,MAAM,KAAK,aAAa,CAAC;AAAA,cACzB,QAAQ,CAAC,MAAM,GAAG,EAAE,MAAM,IAAI,EAAE,KAAK;AAAA,cACrC,UAAU,CAAC,MAAM,GAAG,EAAE,KAAK,IAAI,EAAE,OAAO;AAAA,cACxC,mBAAkB;AAAA,cAClB,YAAW;AAAA,cACX,SAAS;AAAA,gBACR;AAAA,kBACC,KAAK;AAAA,kBACL,OAAO;AAAA,kBACP,WAAW,CAAC,MAAM,EAAE;AAAA,kBACpB,QAAQ,CAAC,MACR,8CAAC,kCAAM,OAAO,GACb;AAAA,iEAAC,iCAAK,MAAM,GAAI,YAAE,OAAM;AAAA,oBACxB,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,YAAE,OAAO,MAAM,GAAG,EAAE,GAAE;AAAA,qBAC7C;AAAA,gBAEF;AAAA,gBACA,EAAE,KAAK,QAAQ,OAAO,QAAQ,SAAS,MAAM,WAAW,CAAC,MAAM,EAAE,MAAM,QAAQ,CAAC,MAAM,6CAAC,iCAAK,MAAM,GAAI,sBAAY,EAAE,IAAI,GAAE,EAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAMlI,EAAE,KAAK,UAAU,OAAO,UAAU,SAAS,MAAM,WAAW,CAAC,MAAM,EAAE,QAAQ,QAAQ,CAAC,MAAM,6CAAC,iCAAK,MAAM,GAAI,sBAAY,EAAE,MAAM,GAAE,EAAQ;AAAA,gBAC1I;AAAA,kBACC,KAAK;AAAA,kBACL,OAAO;AAAA,kBACP,SAAS;AAAA,kBACT,WAAW,CAAC,MAAM,EAAE;AAAA,kBACpB,QAAQ,CAAC,MAAM,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,sBAAY,EAAE,YAAY,GAAE;AAAA,gBAClE;AAAA,gBACA;AAAA,kBACC,KAAK;AAAA,kBACL,OAAO;AAAA,kBACP,SAAS;AAAA,kBACT,WAAW,CAAC,MAAM,EAAE,eAAe;AAAA,kBACnC,aAAa,CAAC,MAAM,EAAE,eAAe;AAAA,kBACrC,QAAQ,CAAC,MACR,8CAAC,iCAAK,MAAM,GACV;AAAA,sBAAE,gBAAgB,QAAQ,EAAE,gBAAgB,SAAY,WAAM,YAAY,EAAE,WAAW;AAAA,oBAIvF,EAAE,mBAAmB,SAAS,EAAE,gBAAgB,QAAQ,EAAE,gBAAgB,UAC1E,6CAAC,iCAAK,IAAG,QAAO,MAAM,GAAG,OAAK,MAAC,qBAAO;AAAA,qBAExC;AAAA,gBAEF;AAAA,gBACA;AAAA,kBACC,KAAK;AAAA,kBACL,OAAO;AAAA,kBACP,SAAS;AAAA;AAAA;AAAA;AAAA,kBAIT,WAAW,CAAC,MAAM,uBAAuB,CAAC;AAAA,kBAC1C,aAAa,CAAC,MAAM,uBAAuB,CAAC;AAAA,kBAC5C,QAAQ,CAAC,MAAM;AACd,0BAAM,QAAQ,uBAAuB,CAAC;AACtC,wBAAI,UAAU,KAAM,QAAO,6CAAC,iCAAK,MAAM,GAAG,oBAAC;AAQ3C,2BACC,8CAAC,kCAAM,OAAO,GACb;AAAA,mEAAC,iCAAK,MAAM,GAAI,sBAAY,OAAO,KAAK,YAAY,IAAI,GAAE;AAAA,sBACzD,EAAE,iBAAiB,QAAQ,EAAE,iBAAiB,UAC9C,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC;AAAA;AAAA,wBACd,YAAY,EAAE,cAAc,KAAK,YAAY,IAAI;AAAA,yBACxD;AAAA,uBAEF;AAAA,kBAEF;AAAA,gBACD;AAAA,cACD;AAAA;AAAA,UACD;AAAA;AAAA;AAAA,IACD;AAAA,KAGF;AAEF;AASA,SAAS,QAAQ,EAAE,MAAM,SAAS,GAAiG;AAClI,QAAM,QAAkB,CAAC;AAEzB,QAAM,MAAM,CAAC,OAAe,KAA8B,QAAiC,SAA4B;AACtH,UAAM,IAAI,gBAAgB,GAAG;AAC7B,UAAM,IAAI,gBAAgB,MAAM;AAChC,QAAI,MAAM,KAAM;AAChB,QAAI,MAAM,QAAQ,MAAM,GAAG;AAC1B,YAAM,KAAK,GAAG,KAAK,IAAI,SAAS,UAAU,YAAY,GAAG,KAAK,YAAY,IAAI,IAAI,YAAY,CAAC,CAAC,EAAE;AAClG;AAAA,IACD;AACA,UAAM,UAAU,IAAI,KAAK,KAAK,IAAI,CAAC;AAUnC,QAAI,KAAK,IAAI,MAAM,IAAI,MAAM;AAC5B,YAAM,KAAK,KAAK,IAAI,MAAM,IAAI,OAC3B,GAAG,KAAK,UACR,GAAG,KAAK,oBAAoB,SAAS,IAAI,MAAM,QAAG,GAAG,cAAc,KAAK,IAAI,MAAM,GAAG,CAAC,CAAC,GAAG;AAAA,IAC9F,WAUS,SAAS,SAAS;AAC1B,YAAM,KAAK,GAAG,KAAK,IAAI,SAAS,IAAI,OAAO,MAAM,OAAO,YAAY,GAAG,KAAK,YAAY,IAAI,CAAC,SAAS,YAAY,GAAG,KAAK,YAAY,IAAI,CAAC,EAAE;AAAA,IAC9I,WAMS,KAAK,IAAI,CAAC,IAAI,gBAAgB;AACtC,YAAM,KAAK,GAAG,KAAK,IAAI,YAAY,CAAC,CAAC,SAAS,YAAY,CAAC,CAAC,EAAE;AAAA,IAC/D,MACK,OAAM,KAAK,GAAG,KAAK,IAAI,SAAS,IAAI,OAAO,MAAM,IAAI,cAAc,KAAK,IAAI,MAAM,GAAG,CAAC,CAAC,EAAE;AAAA,EAC/F;AAEA,MAAI,WAAW,KAAK,SAAS,UAAU,SAAS,OAAO;AAGvD,MAAI,UAAU,KAAK,QAAQ,UAAU,QAAQ,OAAO;AACpD,MAAI,WAAW,KAAK,iBAAiB,UAAU,iBAAiB,OAAO;AAoBvE,QAAM,YAAY,KAAK;AACvB,QAAM,SAAS,cAAc,QAAQ,cAAc,SAChD,iBAAiB,cAAc,IAAI,WAAW,CAAC,CAAC,qBAChD;AACH,QAAM,SAAS,cAAc,QAAQ,cAAc,SAC/C,KAAK,SAAS,cAAc,0CAA0C,OACvE,YAAY,MACX,GAAG,MAAM,wCACT,YAAY,OACX,GAAG,MAAM,2DACT,KAAK,SAAS,cACb,0CACA;AAiBN,MAAI,MAAM,WAAW,KAAK,CAAC,QAAQ;AAClC,WACC,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eAAc,OAAO,EAAE,iBAAiB,GAAG,iBAAiB,QAAQ,GACrG,uDAAC,iCAAK,MAAM,GAAG,iDAAmC,GACnD;AAAA,EAEF;AAYA,QAAM,aAAa,cAAc,QAAQ,cAAc,UAAa,YAAY;AAChF,QAAM,UAAU,UAAU,CAAC,aAAa,KAAK,SAAM,MAAM;AAEzD,SACC;AAAA,IAAC;AAAA;AAAA,MACA,SAAS;AAAA,MACT,QAAQ;AAAA,MACR,MAAM,SAAS,YAAY;AAAA,MAC3B,QAAM;AAAA,MAGN,OAAO,SAAS,EAAE,iBAAiB,GAAG,iBAAiB,QAAQ,IAAI;AAAA,MAEnE,wDAAC,kCAAM,OAAO,GAOZ;AAAA,cAAM,SAAS,KACf,8CAAC,iCAAK,MAAM,GAAG,QAAQ,SAAS,aAAa,UAAW;AAAA,gBAAM,KAAK,QAAU;AAAA,UAAG;AAAA,WAAQ;AAAA,QAIxF,UAAU,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,kBAAO;AAAA,SACzC;AAAA;AAAA,EACD;AAEF;AAQO,SAAS,gBAAgB,EAAE,MAAM,YAAY,GAAwF;AAE3I,QAAM,YAAY,cAAc,KAAK,iBAAiB,KAAK,YAAY;AAEvE,SACC,8CAAC,SAAI,OAAO,YAIX;AAAA,iDAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,kCAAM,OAAO,GACZ;AAAA,WAAK,mBAAmB,QACxB,6CAAC,iCAAK,MAAM,GAAG,QAAO,YAMpB,eAAK,kBAAkB,IACrB,WAAW,cAAc,KAAK,gBAAgB,CAAC,CAAC,iCAChD,WAAW,cAAc,CAAC,KAAK,gBAAgB,CAAC,CAAC,+BACrD;AAAA,MAED,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,eAAK,gBAAe;AAAA,OAC3C,GACD;AAAA,IAOA,8CAAC,SAAI,OAAO,YACX;AAAA,mDAAC,gBAAa,OAAM,oCAAmC;AAAA,MACvD,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,gGAErB;AAAA,MAYC,YACA;AAAA,QAAC;AAAA;AAAA,UACA,YAAW;AAAA,UACX,OAAO,KAAK;AAAA,UACZ,WAAU;AAAA,UACV,MAAM,KAAK;AAAA,UACX,cAAa;AAAA;AAAA,MACd,IACG,cAAc,KAAK,cAAc,KAAK,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAOxD,8CAAC,kCAAM,OAAO,GACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACA,YAAW;AAAA,cACX,OAAO,KAAK;AAAA,cACZ,WAAU;AAAA,cACV,MAAM,KAAK;AAAA,cACX,cAAa;AAAA;AAAA,UACd;AAAA,UACA,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,gIAGrB;AAAA,WACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAQA,8CAAC,SAAI,OAAO,YACX;AAAA,wDAAC,SAAI,OAAO,WACX;AAAA,yDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,sCAAwB;AAAA,YAC9C,6CAAC,gBAAa,QAAQ,SAAS,KAAK,iBAAiB,WAAW,GAAG,OAAM,oBAAmB,MAAM,GAAG;AAAA,aACtG;AAAA,UACA,8CAAC,SAAI,OAAO,WACX;AAAA,yDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,sCAAwB;AAAA,YAC9C,6CAAC,gBAAa,QAAQ,SAAS,KAAK,cAAc,WAAW,GAAG,OAAM,iBAAgB,MAAM,GAAG;AAAA,aAChG;AAAA,UACA,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,sFAErB;AAAA,WACD;AAAA;AAAA,OAEF;AAAA,IASC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOA;AAAA,MAAC;AAAA;AAAA,QACA,OAAM;AAAA,QACN,UAAS;AAAA,QACT,aAAW;AAAA,QAEX,uDAAC,oBAAiB,MAAM,aAAa;AAAA;AAAA,IACtC;AAAA,MAGE,KAAK,SAAS,WAAW,UAAU,KAAK,KAAK,KAAK,mBAAmB,WAAW,gBAClF,8CAAC,SAAI,OAAO,YACX;AAAA,mDAAC,gBAAa,OAAM,0BAAyB;AAAA,MAO7C;AAAA,QAAC;AAAA;AAAA,UACA,WAAW,KAAK,SAAS,aAAa,CAAC;AAAA,UACvC,UAAU,CAAC,UAAU,UAAU,WAAW,gCACvC,UAAU,UAAU,iCACnB;AAAA,UACJ,aAAa;AAAA;AAAA,MACd;AAAA,MAIC,KAAK,SAAS,eACd,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,WAAU,QAAM,MACjD,wDAAC,kCAAM,OAAO,GACb;AAAA,qDAAC,iCAAK,MAAM,GAAG,QAAO,UAAS,2DAA6C;AAAA,QAC5E,6CAAC,iCAAK,MAAM,GAAI,eAAK,QAAQ,aAAY;AAAA,SAC1C,GACD;AAAA,MAGA,KAAK,qBAAqB,KAAK,kBAAkB,WAAW,eAC5D,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YACX;AAAA,qDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,qDAAuC;AAAA,QAC7D,6CAAC,gBAAa,QAAQ,SAAS,KAAK,mBAAmB,WAAW,GAAG,OAAM,sBAAqB;AAAA,SACjG,GACD;AAAA,OAEF;AAAA,IAUA,OAAO,KAAK,KAAK,iBAAiB,CAAC,CAAC,EAAE,SAAS,KAC/C;AAAA,MAAC;AAAA;AAAA,QACA,OAAM;AAAA,QACN,MAAK;AAAA,QACL,UAAS;AAAA,QACT,aAAW;AAAA,QACX,aAAa;AAAA,QAYb;AAAA,UAAC;AAAA;AAAA,YACA,MAAM,OAAO,QAAQ,KAAK,iBAAiB,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,QAAQ,KAAK,OAAO;AAAA,cACxE,KAAK;AAAA,cACL,OAAO;AAAA,cACP,OAAO;AAAA,YACR,EAAE;AAAA,YACF,QAAQ,CAAC,MAAM,YAAY,CAAC;AAAA,YAC5B,YAAW;AAAA;AAAA,QACZ;AAAA;AAAA,IACD;AAAA,IAGD;AAAA,MAAC;AAAA;AAAA,QACA,OAAM;AAAA,QACN,MAAK;AAAA,QACL,UAAS;AAAA,QACT,aAAW;AAAA,QACX,aAAa;AAAA,QAEb,wDAAC,SAAI,OAAO,UACX;AAAA,uDAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YACX;AAAA,yDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,6BAAe;AAAA,YACrC,6CAAC,gBAAa,QAAQ,SAAS,KAAK,gBAAgB,WAAW,GAAG,OAAM,mBAAkB;AAAA,YAG1F,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,uJAAqI;AAAA,aAC3J,GACD;AAAA,UACA,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YACX;AAAA,yDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,0BAAY;AAAA,YAClC,6CAAC,gBAAa,QAAQ,SAAS,KAAK,aAAa,WAAW,GAAG,OAAM,gBAAe;AAAA,aACrF,GACD;AAAA,UACA,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YACX;AAAA,yDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,6BAAe;AAAA,YACrC,6CAAC,gBAAa,QAAQ,SAAS,KAAK,aAAa,WAAW,GAAG,OAAM,8CAA6C,MAAK,WAAU;AAAA,aAClI,GACD;AAAA,WACD;AAAA;AAAA,IACD;AAAA,KAED;AAEF;AAGO,SAAS,iBAAiB,EAAE,MAAM,SAAS,GAA8E;AAC/H,QAAM,WAAW,aAAa,KAAK,aAAa;AAEhD,SACC,8CAAC,SAAI,OAAO,YACX;AAAA,iDAAC,SAAI,OAAO,UACX,uDAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,wDAAC,SAAI,OAAO,YACX;AAAA,mDAAC,kCAAM,MAAM,GAAG,OAAK,MAAC,sBAAQ;AAAA,MAC9B,8CAAC,SAAI,OAAO,WACX;AAAA,qDAAC,iCAAK,MAAM,GAAI,uBAAa,OAAO,WAAW,YAAY,QAAQ,GAAE;AAAA,QACrE,6CAAC,SAAM,SAAS,UAAU,UAAU,aAAa,UAAU,aAAa,GAAG;AAAA,SAC5E;AAAA,OACD,GACD,GACD;AAAA,IAEA,8CAAC,SAAI,OAAO,YACX;AAAA,mDAAC,gBAAa,OAAM,mBAAkB;AAAA,MACtC,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,wFAA0E;AAAA,MAC/F;AAAA,QAAC;AAAA;AAAA,UACA,SAAQ;AAAA,UACR,aAAY;AAAA,UACZ,MAAM,KAAK,QAAQ,CAAC;AAAA,UAKpB,QAAQ;AAAA,UACR,UAAU,CAAC,QAAQ,GAAG,IAAI,MAAM,IAAI,IAAI,OAAO,IAAI,IAAI,UAAU,EAAE,IAAI,IAAI,YAAY,EAAE;AAAA,UACzF,mBAAkB;AAAA,UAClB,YAAW;AAAA,UACX,eAAe;AAAA,YACd,QAAQ,KAAK,aAAa;AAAA,YAC1B,QAAQ,KAAK,YAAY;AAAA,YACzB;AAAA,UACD;AAAA,UACA,SAAS;AAAA,YACR;AAAA,cACC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,WAAW,CAAC,QAAQ,IAAI;AAAA,cACxB,QAAQ,CAAC,QACR,8CAAC,SAAI,OAAO,EAAE,SAAS,QAAQ,KAAK,GAAG,YAAY,UAAU,UAAU,OAAO,GAS7E;AAAA,6DAAC,iCAAK,MAAM,GACV,yBAAe,IAAI,MAAM,IAExB;AAAA,kBAAC;AAAA;AAAA,oBACA,MAAM,WAAW,IAAI,MAAM;AAAA,oBAC3B,QAAO;AAAA,oBACP,KAAI;AAAA,oBACJ,OAAO;AAAA,oBAEN,cAAI;AAAA;AAAA,gBACN,IAEC,IAAI,QACR;AAAA,gBACC,IAAI,kBAAkB,6CAAC,kCAAM,MAAK,WAAU,UAAU,GAAG,oBAAM;AAAA,gBAC/D,IAAI,gBAAgB,6CAAC,kCAAM,MAAK,WAAU,UAAU,GAAG,uBAAS;AAAA,iBAClE;AAAA,YAEF;AAAA,YACA;AAAA,cACC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,WAAW,CAAC,QAAQ,IAAI;AAAA,cACxB,QAAQ,CAAC,QAAQ,6CAAC,iCAAK,MAAM,GAAI,cAAI,SAAQ;AAAA,YAC9C;AAAA,YACA;AAAA,cACC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,WAAW,CAAC,QAAQ,IAAI;AAAA,cACxB,aAAa,CAAC,QAAQ,IAAI,YAAY,IAAI,UAAU;AAAA;AAAA;AAAA,cAGpD,QAAQ,CAAC,QAAQ,IAAI,WAClB,6CAAC,iCAAK,MAAM,GAAI,cAAI,UAAS,IAC7B,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,cAAI,UAAU,UAAI;AAAA,YAC5C;AAAA,YACA;AAAA,cACC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,SAAS;AAAA,cACT,WAAW,CAAC,QAAQ,IAAI;AAAA,cACxB,QAAQ,CAAC,QAAQ,6CAAC,iCAAK,MAAM,GAAI,sBAAY,IAAI,QAAQ,GAAE;AAAA,YAC5D;AAAA,YACA;AAAA,cACC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,SAAS;AAAA,cACT,WAAW,CAAC,QAAQ,aAAa,IAAI,cAAc;AAAA,cACnD,aAAa,CAAC,QAAQ;AAAE,sBAAM,IAAI,aAAa,IAAI,cAAc;AAAG,uBAAO,MAAM,OAAO,OAAO,cAAc,GAAG,CAAC;AAAA,cAAE;AAAA;AAAA;AAAA,cAGnH,QAAQ,CAAC,QAAQ,aAAa,IAAI,cAAc,MAAM,OACnD,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,oBAAC,IACtB,6CAAC,iCAAK,MAAM,GAAI,wBAAc,IAAI,gBAA0B,CAAC,GAAE;AAAA,YACnE;AAAA,YACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAYC,KAAK;AAAA;AAAA;AAAA,cAGL,OAAO;AAAA,cACP,SAAS;AAAA,cACT,WAAW,CAAC,QAAQ,aAAa,IAAI,YAAY;AAAA,cACjD,aAAa,CAAC,QAAQ;AACrB,sBAAM,QAAQ,aAAa,IAAI,kBAAkB;AACjD,uBAAO,UAAU,OAAO,OAAO,YAAY,OAAO,KAAK,YAAY,IAAI;AAAA,cACxE;AAAA,cACA,QAAQ,CAAC,QAAQ;AAMhB,sBAAM,QAAQ,aAAa,IAAI,YAAY;AAM3C,oBAAI,UAAU,QAAQ,UAAU,EAAG,QAAO,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,oBAAC;AAChE,sBAAM,QAAQ,aAAa,IAAI,kBAAkB;AACjD,uBACC,8CAAC,kCAAM,OAAO,GACZ;AAAA,4BAAU,QAAQ,6CAAC,iCAAK,MAAM,GAAI,sBAAY,OAAO,KAAK,YAAY,IAAI,GAAE;AAAA,kBAC7E,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE;AAAA,kCAAc,OAAO,CAAC;AAAA,oBAAE;AAAA,qBAAiB;AAAA,mBAChE;AAAA,cAEF;AAAA,YACD;AAAA,UACD;AAAA;AAAA,MACD;AAAA,MASC,EAAE,KAAK,iBAAiB,QAAQ,aAAa,KAAK,aAAa,MAAM,SACrE,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC;AAAA;AAAA,QACsB;AAAA,QACzC,aAAa,KAAK,aAAa,MAAM,OACnC,iFACA;AAAA,QACF;AAAA,QAAI;AAAA,SACN;AAAA,MAGA,KAAK,iBAAiB,QAAQ,aAAa,KAAK,aAAa,MAAM;AAAA;AAAA;AAAA;AAAA,MAKnE,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAIwC;AAAA;AAAA,QAC/C,YAAY,aAAa,KAAK,aAAa,KAAK,GAAG,KAAK,YAAY,IAAI;AAAA,QAAE;AAAA,QAAM;AAAA,QAC3F,aAAa,KAAK,YAAY,MAAM,OAClC,iBACC,KAAK,sBAAsB,KAAK,IAChC,OAAO,YAAa,KAAK,eAA2B,KAAK,kBAA6B,CAAC,OAAO,YAAY,KAAK,YAAsB,CAAC,iCACtI,GAAG,YAAY,KAAK,YAAsB,CAAC;AAAA,QAAW;AAAA,QAAI;AAAA,QAC5C,YAAY,KAAK,oBAAoB,CAAC;AAAA,QAAE;AAAA,QACzD,KAAK,qBAAqB,IAAI,KAAK;AAAA,QAAI;AAAA,QAOvC,aAAa,KAAK,cAAc,MAAM,SAAS,KAAK,kBAAkB,MAAM,KAAK,oBAAoB,MACrG,8EACE;AAAA;AAAA,UAAI;AAAA,UAA4B,YAAY,KAAK,kBAAkB,CAAC;AAAA,UAAE;AAAA,WAExE;AAAA,SAEF;AAAA,OAEF;AAAA,KAYE,KAAK,iBAAiB,UAAU,KAAK,KACtC;AAAA,MAAC;AAAA;AAAA,QACA,OAAM;AAAA,QACN,MAAK;AAAA,QACL,UAAS;AAAA,QAET,uDAAC,qBAAkB,MAAM,KAAK,mBAAmB,CAAC,GAAG;AAAA;AAAA,IACtD;AAAA,KAEF;AAEF;AAaO,SAAS,cAAc,OAAqC;AAClE,QAAM,SAAS,MAAM,CAAC;AACtB,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,OAAO,OAAO;AACpB,SAAO,OAAO,SAAS,YAAY,OAAO,SAAS,IAAI,IAAI,OAAO;AACnE;AAeO,SAAS,OAAO,UAA2C;AACjE,QAAM,YAAY;AAClB,QAAM,QAAQ;AAEd,QAAM,QAAQ,SACZ,IAAI,CAAC,aAAa;AAAA,IAClB,OAAO,QAAQ;AAAA,IACf,MAAM,cAAc,QAAQ,KAAK;AAAA,IACjC,OAAO,gBAAgB,QAAQ,MAAM,CAAC,GAAG,KAAK,KAAK;AAAA,EACpD,EAAE,EACD,OAAO,CAAC,QACR,IAAI,SAAS,QAAQ,IAAI,OAAO,KAAK,IAAI,SAAS,SAAS;AAE7D,MAAI,MAAM,SAAS,EAAG,QAAO;AAC7B,QAAM,SAAS,CAAC,GAAG,KAAK,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,IAAI;AACxD,QAAM,OAAO,OAAO,CAAC;AACrB,QAAM,QAAQ,OAAO,OAAO,SAAS,CAAC;AACtC,QAAM,QAAQ,KAAK,OAAO,MAAM;AAChC,MAAI,QAAQ,MAAO,QAAO;AAE1B,SAAO,GAAG,MAAM,KAAK,+BAA+B,cAAc,MAAM,MAAM,CAAC,CAAC,YAC1E,KAAK,KAAK,MAAM,cAAc,KAAK,MAAM,CAAC,CAAC,iBAAY,KAAK,MAAM,KAAK,CAAC,oBACxE,YAAY,MAAM,KAAK,CAAC;AAC/B;AAmBO,SAAS,cAAc,WAAoB,UAAmB,MAAkC;AACtG,MAAI,CAAC,aAAa,CAAC,SAAU,QAAO;AACpC,QAAM,QAAQ,aAAa,WACxB,sFACA,YACC,mCACA;AACJ,SAAO,GAAG,KAAK,2CAA2C,IAAI;AAC/D;AAgBO,SAAS,SAAS,QAAsC,MAA2D;AACzH,MAAI,CAAC,UAAU,OAAO,WAAW,EAAG,QAAO;AAC3C,QAAM,SAAS,WAAW,OAAO,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,KAAK,CAAC,EAAE,EAAE,CAAC;AAC/E,SAAO,SAAS,aAAa,MAAM,IAAI;AACxC;AAgBO,SAAS,wBAAwB,MAAsC;AAC7E,QAAM,UAAU,gBAAgB,KAAK,MAAM;AAC3C,QAAM,YAAY,aAAa,KAAK,eAAe;AACnD,MAAI,YAAY,QAAQ,cAAc,KAAM,QAAO;AACnD,SAAO,YAAY;AACpB;AAGO,SAAS,aAAa,EAAE,KAAK,GAA8C;AACjF,QAAM,WAAW,KAAK,YAAY,CAAC;AAInC,QAAM,QAAQ,CAAC,GAAG,QAAQ,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,MAAM,SAAS,EAAE,MAAM,MAAM,EAAE,CAAC,GAAG,SAAS,CAAC;AAK1F,QAAM,WAAW,KAAK,SAAS,CAAC;AAMhC,QAAM,QAAQ,SAAS,OAAO,CAAC,SAAS,KAAK,MAAM,WAAW,aAAa;AAC3E,QAAM,cAAc,SAAS,OAAO,CAAC,SAAS,KAAK,MAAM,WAAW,aAAa;AAQjF,QAAM,SAAS,MAAM;AAAA,IAAQ,CAAC,SAC7B,KAAK,MAAM,WAAW,gBACnB,CAAC,IACD,CAAC;AAAA,MACF,KAAK,KAAK;AAAA,MACV,OAAO,KAAK;AAAA,MACZ,OAAO,KAAK,MAAM;AAAA,MAClB,wBAAwB,KAAK;AAAA;AAAA;AAAA;AAAA,MAI7B,GAAI,SAAS,SAAS,IACnB;AAAA,QACD,UAAU,SAAS,QAAQ,CAAC,YAAY;AACvC,gBAAM,OAAO,QAAQ,MAAM,KAAK,CAAC,MAAM,EAAE,QAAQ,KAAK,GAAG;AACzD,gBAAM,QAAQ,OAAO,gBAAgB,KAAK,KAAK,IAAI;AACnD,iBAAO,UAAU,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,QAAQ,KAAK,OAAO,QAAQ,OAAO,MAAM,CAAC;AAAA,QAChF,CAAC;AAAA,MACF,IACE,CAAC;AAAA,MACJ,GAAI,KAAK,MAAM,WAAW,aAAa,KAAK,MAAM,cAC/C,EAAE,SAAS,EAAE,MAAM,UAAU,KAAK,MAAM,WAAW,EAAE,EAAE,IACvD,CAAC;AAAA,IACL,CAAC;AAAA,EACH;AAGA,QAAM,UAAU,KAAK,gBAAgB;AAErC,SACC,8CAAC,SAAI,OAAO,YAIX;AAAA,iDAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAM,UAAU,gBAAgB,WAAW,QAAM,MAC7E,wDAAC,kCAAM,OAAO,GACb;AAAA,mDAAC,iCAAK,MAAM,GAAG,QAAO,UAKpB,oBAAU,iBAAiB,sBAC7B;AAAA,MACA,6CAAC,iCAAK,MAAM,GAAG,OAAO,SAAU,eAAK,mBAAkB;AAAA,OACxD,GACD;AAAA,IAmBC,SAAS,UAAU,KACpB;AAAA,MAAC;AAAA;AAAA,QACA,OAAM;AAAA,QACN,MAAM;AAAA,QACN,QAAQ,CAAC,YAAY,QAAQ;AAAA,QAC7B,SAAS;AAAA,UACR;AAAA,YACC,KAAK;AAAA,YACL,OAAO,KAAK,oBAAoB;AAAA,YAChC,WAAW,CAAC,YAA4B,QAAQ;AAAA,YAChD,QAAQ,CAAC,YAA4B,QAAQ;AAAA,UAC9C;AAAA,UACA,GAAG,MAAM,IAAI,CAAC,UAAU;AAAA,YACvB,KAAK,KAAK;AAAA,YACV,OAAO,KAAK;AAAA,YACZ,SAAS;AAAA,YACT,WAAW,CAAC,YACX,gBAAgB,QAAQ,MAAM,KAAK,CAAC,MAAM,EAAE,QAAQ,KAAK,GAAG,GAAG,KAAK;AAAA,YACrE,QAAQ,CAAC,YAA4B;AACpC,oBAAM,OAAO,QAAQ,MAAM,KAAK,CAAC,MAAM,EAAE,QAAQ,KAAK,GAAG;AACzD,oBAAM,QAAQ,gBAAgB,MAAM,KAAK;AACzC,kBAAI,UAAU,KAAM,QAAO;AAI3B,oBAAM,QAAQ,MAAM,UAAU,CAAC,OAAO,GAAG,QAAQ,KAAK,GAAG;AACzD,oBAAM,SAAS,QAAQ,IACpB,gBAAgB,QAAQ,MAAM,KAAK,CAAC,MAAM,EAAE,QAAQ,MAAM,QAAQ,CAAC,EAAG,GAAG,GAAG,KAAK,IACjF;AACH,oBAAM,OAAO,MAAM,0BAA0B;AAC7C,oBAAM,WAAW,QAAQ,KAAK,SAAS,QAAQ,WAAW,QAAQ,UAAU;AAC5E,qBAAO,WACJ,GAAG,YAAY,KAAK,CAAC,SAAM,cAAc,MAAM,CAAC,CAAC,OAAO,YAAY,MAAM,CAAC,KAC3E,YAAY,KAAK;AAAA,YACrB;AAAA,UACD,EAAE;AAAA,QACH;AAAA;AAAA,IACD;AAAA,IAGC,OAAO,QAAQ,KAAK,6CAAC,iCAAK,MAAM,GAAI,iBAAO,QAAQ,GAAE;AAAA,IAEtD,6CAAC,eAAY,QAAgB,aAAa,KAAK,eAAe,sBAAsB;AAAA,KAElF,KAAK,UAAU,UAAU,KAAK,KAC/B;AAAA,MAAC;AAAA;AAAA,QACA,OAAM;AAAA,QAIN,UAAS;AAAA,QAET,uDAAC,SAAI,OAAO,UACT,gBAAK,YAAY,CAAC,GAAG,IAAI,CAAC,YAC3B,6CAAC,iCAAuB,SAAS,GAAG,QAAQ,GAAG,MAAK,eACnD,wDAAC,SAAI,OAAO,YACX;AAAA,uDAAC,kCAAM,MAAM,GAAG,OAAK,MAAE,kBAAQ,OAAM;AAAA,UACrC,6CAAC,gBAAa,QAAQ,QAAQ,OAAO,OAAO,QAAQ,OAAO,MAAM,GAAG;AAAA,UACpE,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,kBAAQ,MAAK;AAAA,WACpC,KALU,QAAQ,GAMnB,CACA,GACF;AAAA;AAAA,IACD;AAAA,IAGA,YAAY,SAAS,KACrB,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC;AAAA;AAAA,MACR,YAAY,IAAI,CAAC,SAAS,KAAK,MAAM,YAAY,CAAC,EAAE,KAAK,IAAI;AAAA,MAAE;AAAA,OAE5E;AAAA,KAGF;AAEF;AAUA,SAAS,kBAAkB,EAAE,KAAK,GAAgD;AACjF,SACC;AAAA,IAAC;AAAA;AAAA,MACG,SAAQ;AAAA,MACR,aAAY;AAAA,MACZ;AAAA,MACA,QAAQ,CAAC,SAAS,KAAK;AAAA,MACvB,UAAU,CAAC,SAAS,KAAK;AAAA,MACzB,mBAAkB;AAAA,MAClB,YAAW;AAAA,MACX,SAAS;AAAA,QACR;AAAA,UACC,KAAK;AAAA,UACL,OAAO;AAAA,UACP,WAAW,CAAC,SAAS,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA,UAK1B,QAAQ,CAAC,SACR,6CAAC,iCAAK,MAAM,GACX,uDAAC,OAAE,MAAM,KAAK,MAAM,QAAO,UAAS,KAAI,uBAAsB,OAAO,YAAa,eAAK,MAAK,GAC7F;AAAA,QAEF;AAAA,QACA;AAAA,UACC,KAAK;AAAA,UACL,OAAO;AAAA,UACP,SAAS;AAAA,UACT,WAAW,CAAC,SAAS,KAAK;AAAA,UAC1B,QAAQ,CAAC,SAAS,6CAAC,iCAAK,MAAM,GAAI,sBAAY,KAAK,QAAQ,GAAE;AAAA,QAC9D;AAAA,QACA;AAAA,UACC,KAAK;AAAA,UACL,OAAO;AAAA,UACP,SAAS;AAAA,UACT,WAAW,CAAC,SAAS,aAAa,KAAK,cAAc;AAAA,UACrD,aAAa,CAAC,SAAS;AAAE,kBAAM,IAAI,aAAa,KAAK,cAAc;AAAG,mBAAO,MAAM,OAAO,OAAO,cAAc,GAAG,CAAC;AAAA,UAAE;AAAA;AAAA;AAAA,UAGrH,QAAQ,CAAC,SAAS,aAAa,KAAK,cAAc,MAAM,OACrD,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,oBAAC,IACtB,6CAAC,iCAAK,MAAM,GAAI,wBAAc,KAAK,gBAA0B,CAAC,GAAE;AAAA,QACpE;AAAA,MACD;AAAA;AAAA,EACD;AAEL;AA0BA,IAAM,mBAAmB;AAElB,SAAS,cAAc,MAA4E;AACzG,MAAI,QAAQ;AACZ,MAAI,QAAQ;AACZ,aAAW,OAAO,MAAM;AAKvB,QAAI,IAAI,QAAQ,WAAW,KAAM;AACjC,UAAM,SAAS,gBAAgB,IAAI,MAAM;AACzC,UAAM,SAAS,gBAAgB,IAAI,MAAM;AAGzC,QAAI,WAAW,QAAQ,WAAW,KAAM;AACxC,aAAS;AACT,aAAS;AAAA,EACV;AACA,SAAO,QAAQ,KAAK,QAAQ,IAAI,QAAQ,QAAQ;AACjD;AAYO,SAAS,aACf,KACA,WACgB;AAChB,QAAM,OAAO,IAAI;AACjB,MAAI,cAAc,QAAQ,aAAa,EAAG,QAAO;AACjD,MAAI,SAAS,QAAQ,SAAS,UAAa,CAAC,OAAO,SAAS,IAAI,KAAK,OAAO,EAAG,QAAO;AActF,QAAM,QAAQ,gBAAgB,IAAI,MAAM;AACxC,MAAI,UAAU,QAAQ,QAAQ,iBAAkB,QAAO;AAEvD,SAAO,OAAO;AACf;AAyBO,SAAS,uBAAuB,UAAwC;AAC9E,MAAI,SAAS,iBAAiB,QAAQ,SAAS,iBAAiB,OAAW,QAAO;AAClF,MAAI,CAAC,OAAO,SAAS,SAAS,IAAI,KAAK,SAAS,QAAQ,EAAG,QAAO;AAClE,SAAQ,SAAS,eAAe,SAAS,OAAQ;AAClD;AAEO,SAAS,kBAAkB,MAA0C;AAU3E,QAAM,gBAAgB,SAAS,KAAK,SAAS,WAAW;AACxD,MAAI,cAAc,WAAW,cAAe,QAAO;AACnD,QAAM,UAAU,gBAAgB,aAAa;AAC7C,QAAM,SAAS,aAAa,KAAK,eAAe;AAChD,MAAI,YAAY,QAAQ,WAAW,QAAQ,UAAU,GAAG;AACvD,WAAO,YAAY,kBAAkB,qDAAqD;AAAA,EAC3F;AACA,QAAM,UAAU,UAAU;AAI1B,QAAM,UAAU,aAAa,gBAAgB,SAAS,KAAK,QAAQ,WAAW,CAAC,CAAC;AAChF,QAAM,aAAa,YAAY,QAAQ,UAAU;AACjD,SAAO,cAAc,KAAK,QAAQ,WAAW,YAC1C,QAAQ,SAAS,IAAI,qBAAqB,YAAY,MAAM,CAAC,OAAO,YAAY,WAAW,MAAM,CAAC,+BAA+B,IACjI,GAAG,OAAO;AACd;AAaO,SAAS,iBAAiB,MAA0C;AAC1E,QAAM,gBAAgB,SAAS,KAAK,gBAAgB,WAAW;AAC/D,QAAM,cAAc,SAAS,KAAK,QAAQ,WAAW;AACrD,MAAI,cAAc,WAAW,cAAe,QAAO;AACnD,MAAI,YAAY,WAAW,cAAe,QAAO;AACjD,QAAM,WAAW,gBAAgB,aAAa;AAC9C,QAAM,SAAS,gBAAgB,WAAW;AAC1C,MAAI,aAAa,QAAQ,WAAW,QAAQ,UAAU,GAAG;AACxD,WAAO,YAAY,kBAAkB,kDAAkD;AAAA,EACxF;AACA,SAAO,GAAG,KAAK,MAAM,WAAW,MAAM,CAAC;AACxC;AAGO,SAAS,sBAAsB,EAAE,KAAK,GAAuD;AAEnG,QAAM,YAAY,cAAc,KAAK,QAAQ,CAAC,CAAC;AAE/C,SACC,8CAAC,SAAI,OAAO,YACX;AAAA,iDAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,eACjC,uDAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,eAAK,oBAAmB,GAC/C;AAAA,IAEA,8CAAC,SAAI,OAAO,YACX;AAAA,mDAAC,gBAAa,OAAM,0BAAyB;AAAA,MAC7C,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC;AAAA;AAAA,QAOnB;AAAA,QAAI;AAAA,QAEJ;AAAA,QAAI;AAAA,SAEN;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACA,SAAQ;AAAA,UACR,aAAY;AAAA,UACZ,MAAM,KAAK,QAAQ,CAAC;AAAA,UACpB,QAAQ,CAAC,QAAQ,IAAI;AAAA,UACrB,UAAU,CAAC,QAAQ,IAAI;AAAA,UACvB,mBAAkB;AAAA,UAClB,YAAW;AAAA,UACX,eAAe;AAAA,YACd,QAAQ,KAAK,aAAa;AAAA,YAC1B,QAAQ,KAAK,YAAY;AAAA,YACzB;AAAA,UACD;AAAA,UACA,SAAS;AAAA,YACR;AAAA,cACC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,WAAW,CAAC,QAAQ,IAAI;AAAA,cACxB,QAAQ,CAAC,QAAQ,6CAAC,iCAAK,MAAM,GAAI,cAAI,UAAS;AAAA,YAC/C;AAAA,YACA;AAAA,cACC,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAOL,OAAO;AAAA,cACP,SAAS;AAAA,cACT,WAAW,CAAC,QAAQ,gBAAgB,IAAI,MAAM;AAAA,cAC9C,QAAQ,CAAC,QAAQ,6CAAC,gBAAa,QAAQ,IAAI,QAAQ,OAAO,GAAG,IAAI,QAAQ,WAAW,MAAM,GAAG;AAAA,YAC9F;AAAA,YACA;AAAA,cACC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,SAAS;AAAA,cACT,WAAW,CAAC,QAAQ,gBAAgB,IAAI,MAAM;AAAA,cAC9C,QAAQ,CAAC,QAAQ,6CAAC,gBAAa,QAAQ,IAAI,QAAQ,OAAO,GAAG,IAAI,QAAQ,WAAW,MAAM,GAAG;AAAA,YAC9F;AAAA,YACA;AAAA,cACC,KAAK;AAAA;AAAA,cAEL,YAAY;AAAA,cACZ,OAAO;AAAA,cACP,SAAS;AAAA,cACT,WAAW,CAAC,QAAQ,gBAAgB,IAAI,MAAM;AAAA,cAC9C,QAAQ,CAAC,QAAQ,6CAAC,gBAAa,QAAQ,IAAI,QAAQ,OAAO,GAAG,IAAI,QAAQ,WAAW,MAAM,GAAG;AAAA,YAC9F;AAAA,YACA;AAAA,cACC,KAAK;AAAA;AAAA,cAEL,YAAY;AAAA,cACZ,OAAO;AAAA,cACP,SAAS;AAAA,cACT,WAAW,CAAC,QAAQ,gBAAgB,IAAI,OAAO;AAAA,cAC/C,aAAa,CAAC,QAAQ;AACrB,sBAAM,QAAQ,gBAAgB,IAAI,OAAO;AACzC,uBAAO,UAAU,OAAO,OAAO,YAAY,OAAO,KAAK,YAAY,IAAI;AAAA,cACxE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAOA,QAAQ,CAAC,QAAQ;AAChB,sBAAM,UAAU,SAAS,IAAI,SAAS,WAAW;AACjD,uBAAO,QAAQ,WAAW,OACvB,6CAAC,iCAAK,MAAM,GAAI,sBAAY,QAAQ,OAAO,KAAK,YAAY,IAAI,GAAE,IAClE,6CAAC,gBAAa,QAAQ,SAAS,OAAO,GAAG,IAAI,QAAQ,YAAY,MAAM,GAAG;AAAA,cAC9E;AAAA,YACD;AAAA,YACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAkBC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,cAKT,WAAW,CAAC,QAAQ,aAAa,KAAK,SAAS;AAAA,cAC/C,aAAa,CAAC,QAAQ;AACrB,sBAAM,QAAQ,aAAa,KAAK,SAAS;AACzC,uBAAO,UAAU,OAAO,OAAO,GAAG,MAAM,QAAQ,CAAC,CAAC;AAAA,cACnD;AAAA,cACA,QAAQ,CAAC,QAAQ;AAChB,sBAAM,QAAQ,aAAa,KAAK,SAAS;AACzC,oBAAI,UAAU,MAAM;AAInB,wBAAM,QAAQ,gBAAgB,IAAI,MAAM;AACxC,wBAAM,WAAW,UAAU,QAAQ,QAAQ;AAI3C,wBAAM,aAAa,IAAI,QAAQ,WAAW;AAC1C,yBACC,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,cAAY,GAAG,IAAI,QAAQ,mBAC9C,uBACE,qCACA,WAAW,6BAA6B,UAC5C;AAAA,gBAEF;AACA,oBAAI,UAAU,GAAG;AAGhB,yBAAO,6CAAC,iCAAK,MAAM,GAAG,sBAAQ;AAAA,gBAC/B;AACA,uBACC,8CAAC,iCAAK,MAAM,GACV;AAAA,wBAAM,QAAQ,CAAC;AAAA,kBAAE;AAAA,mBACnB;AAAA,cAEF;AAAA,YACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAMD;AAAA;AAAA,MACD;AAAA,OACD;AAAA,KAEE,KAAK,UAAU,UAAU,KAAK,KAC/B,8CAAC,SAAI,OAAO,YACX;AAAA,mDAAC,gBAAa,OAAM,qBAAoB;AAAA,MACxC,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,kLAGrB;AAAA,MAeA,6CAAC,iBAAc,MAAM,KAAK,YAAY,CAAC,GAAG,UAAU,KAAK,YAAY,MAAM;AAAA,OAC5E;AAAA,IAaD,8CAAC,SAAI,OAAO,YACV;AAAA,WAAK,wBACL,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAGY,mIAGhC;AAAA,MAKA,aAAa,KAAK,mBAAmB,MAAM,QAAS,KAAK,sBAAiC,KAC1F,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAClB;AAAA,oBAAY,KAAK,qBAA+B,KAAK,YAAY,IAAI;AAAA,QAAE;AAAA,SAEzE;AAAA,MAIA,aAAa,KAAK,kBAAkB,MAAM,QAAS,KAAK,qBAAgC,KACxF,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAClB;AAAA,oBAAY,KAAK,kBAA4B;AAAA,QAAE;AAAA,QAC/C,KAAK,uBAAuB,IAAI,KAAK;AAAA,QAAI;AAAA,QACnB,KAAK,uBAAuB,IAAI,OAAO;AAAA,QAAO;AAAA,SACtE;AAAA,OAEF;AAAA,KACD;AAEF;AAGA,IAAM,aAAmF;AAAA,EACxF,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,SAAS;AACV;AAGA,IAAM,aAA0C;AAAA,EAC/C,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,SAAS;AACV;AASO,SAAS,iBAAiB,EAAE,KAAK,GAAmD;AAI1F,QAAM,SAAS,KAAK,UAAU,CAAC;AAC/B,QAAM,UAAU,OAAO,OAAO,CAAC,MAAM,EAAE,WAAW,MAAM,EAAE;AAC1D,QAAM,UAAU,OAAO,OAAO,CAAC,MAAM,EAAE,WAAW,MAAM,EAAE;AAM1D,QAAM,UACL,OAAO,WAAW,IACf,8DACA,KAAK,YAAY,SAChB,8JAMA,YAAY,KAAK,YAAY,IAC5B,uBAAuB,OAAO,SAAS,UAAU,OAAO,SAAS,OAAO,SAAS,UAAU,YAAY,IAAI,KAAK,GAAG,oHACnH,GAAG,OAAO,aAAa,OAAO;AAEpC,SACC,8CAAC,SAAI,OAAO,YACX;AAAA,iDAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAM,WAAW,KAAK,OAAO,KAAK,WAAW,QAAM,MAC/E,uDAAC,iCAAK,MAAM,GAAI,mBAAQ,GACzB;AAAA,IAEA,6CAAC,SAAI,OAAO,YACV,iBAAO,IAAI,CAAC,SACZ,6CAAC,iCAAmB,SAAS,GAAG,QAAQ,GAAG,MAAK,eAC/C,wDAAC,SAAI,OAAO,YACX;AAAA,oDAAC,iCAAK,OAAM,UAAS,SAAQ,iBAAgB,KAAK,GACjD;AAAA,qDAAC,iCAAK,MAAM,GAAG,QAAO,YAAY,eAAK,OAAM;AAAA,QAC7C,6CAAC,kCAAM,MAAM,WAAW,KAAK,MAAM,KAAK,WAAW,UAAU,GAAI,qBAAW,KAAK,MAAM,KAAK,KAAK,QAAO;AAAA,SACzG;AAAA,MACA,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,eAAK,QAAO;AAAA,MACjC,KAAK,UAAU,6CAAC,iCAAK,MAAM,GAAI,eAAK,QAAO;AAAA,OAC7C,KARU,KAAK,EAShB,CACA,GACF;AAAA,KACD;AAEF;;;AP71DG,IAAAC,sBAAA;AAzQH,IAAM,aAAkC;AAAA,EACvC,SAAS;AAAA,EACT,KAAK;AAAA,EACL,YAAY;AAAA,EACZ,UAAU;AACX;AAWA,IAAM,WAAgC;AAAA,EACrC,SAAS;AAAA,EACT,KAAK;AAAA,EACL,YAAY;AAAA,EACZ,UAAU;AAAA,EACV,cAAc;AACf;AAGA,SAAS,SAAS,UAAwC;AACzD,SAAO;AAAA,IACN,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ,QAAQ;AAAA,IACR,cAAc,aAAa,WAAW,iBAAiB,aAAa;AAAA,IACpE,OAAO;AAAA,IACP,SAAS,WAAW,IAAI;AAAA,IACxB,MAAM;AAAA,IACN,YAAY,WAAW,MAAM;AAAA,IAC7B,SAAS;AAAA,IACT,cAAc;AAAA,IACd,QAAQ;AAAA,IACR,YAAY;AAAA,EACb;AACD;AAGA,IAAM,aAAkC;AAAA,EACvC,SAAS;AAAA,EACT,KAAK;AAAA,EACL,YAAY;AAAA,EACZ,UAAU;AACX;AAGA,IAAM,cAAmC;AAAA,EACxC,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AAAA,EACT,SAAS;AAAA,EACT,gBAAgB;AAAA,EAChB,qBAAqB;AAAA,EACrB,QAAQ;AACT;AAGA,SAAS,YAAY,UAAwC;AAC5D,SAAO;AAAA,IACN,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ,QAAQ,aAAa,WAAW,iBAAiB,iDAAiD;AAAA,IAClG,cAAc;AAAA,IACd,OAAO;AAAA,IACP,SAAS,WAAW,IAAI;AAAA,IACxB,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY,WAAW,MAAM;AAAA,IAC7B,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,YAAY;AAAA,EACb;AACD;AAgBA,SAAS,YAAY,UAAwC;AAC5D,SAAO;AAAA,IACN,YAAY;AAAA,IACZ,YAAY,WAAW,UAAU,YAAY,IAAI,IAAI;AAAA,IACrD,QAAQ,OAAO,WAAW,UAAU,QAAQ,IAAI,WAAW,aAAa,iDAAiD;AAAA,IACzH,cAAc;AAAA,IACd,OAAO,WAAW,kBAAkB;AAAA,IACpC,SAAS,WAAW,IAAI;AAAA,IACxB,MAAM;AAAA,IACN,UAAU;AAAA,IACV,YAAY,WAAW,MAAM;AAAA,IAC7B,SAAS;AAAA;AAAA;AAAA,IAGT,WAAW;AAAA,IACX,QAAQ;AAAA,IACR,YAAY;AAAA,EACb;AACD;AAGA,IAAM,YAAiC,EAAE,SAAS,QAAQ,KAAK,IAAI,YAAY,YAAY,UAAU,OAAO;AAG5G,IAAM,cAAmC,EAAE,SAAS,QAAQ,KAAK,EAAE;AAGnE,IAAM,YAAiC;AAAA,EACtC,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AAAA,EACT,cAAc;AAAA,EACd,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,OAAO;AAAA,EACP,aAAa;AACd;AAcA,IAAM,SAAmF;AAAA,EACxF,EAAE,KAAK,QAAQ,OAAO,QAAQ,MAAM,kBAAkB;AAAA,EACtD,EAAE,KAAK,SAAS,OAAO,SAAS,MAAM,mBAAmB;AAAA,EACzD,EAAE,KAAK,WAAW,OAAO,WAAW,MAAM,mBAAmB;AAAA,EAC7D,EAAE,KAAK,QAAQ,OAAO,QAAQ,MAAM,oBAAoB;AACzD;AA6BA,IAAM,gBAAgB,CAAC,eAAe,WAAW,mBAAmB;AAEpE,IAAM,SAAmE;AAAA,EACxE,EAAE,IAAI,YAAY,QAAQ,sBAAsB,OAAO,WAAW;AAAA,EAClE,EAAE,IAAI,eAAe,QAAQ,eAAe,OAAO,cAAc;AAAA,EACjE,EAAE,IAAI,qBAAqB,QAAQ,qBAAqB,OAAO,oBAAoB;AAAA,EACnF,EAAE,IAAI,WAAW,QAAQ,WAAW,OAAO,UAAU;AACtD;AA4BA,SAAS,cAAc;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD,GAKuB;AACtB,QAAM,WAAO,sBAAwC,CAAC,CAAC;AACvD,QAAM,CAAC,YAAY,aAAa,QAAI,wBAAS,UAAU,QAAQ;AAO/D,QAAM,CAAC,OAAO,QAAQ,QAAI,wBAAS,MAAM;AAGzC,gBAAAC,QAAM,UAAU,MAAM;AAAE,aAAS,MAAM;AAAA,EAAE,GAAG,CAAC,OAAO,OAAO,OAAO,GAAG,CAAC;AAEtE,QAAM,gBAAY;AAAA,IACjB,CAAC,UAA+B;AAC/B,YAAM,eAAe,OAAO,UAAU,CAAC,MAAM,EAAE,QAAQ,KAAK;AAC5D,UAAI,YAA2B;AAE/B,UAAI,MAAM,QAAQ,gBAAgB,MAAM,QAAQ,YAAa,cAAa,eAAe,KAAK,OAAO;AACrG,UAAI,MAAM,QAAQ,eAAe,MAAM,QAAQ,UAAW,cAAa,eAAe,IAAI,OAAO,UAAU,OAAO;AAClH,UAAI,MAAM,QAAQ,OAAQ,aAAY;AACtC,UAAI,MAAM,QAAQ,MAAO,aAAY,OAAO,SAAS;AAErD,UAAI,cAAc,KAAM;AAExB,YAAM,eAAe;AACrB,YAAM,OAAO,OAAO,SAAS;AAC7B,UAAI,CAAC,KAAM;AAEX,oBAAc,KAAK;AACnB,eAAS,KAAK,GAAG;AACjB,WAAK,QAAQ,SAAS,GAAG,MAAM;AAAA,IAChC;AAAA,IACA,CAAC,OAAO,QAAQ;AAAA,EACjB;AAEA,SACC,8CAAC,kCAAM,OAAO,GACb;AAAA,kDAAC,SAAI,OAAO,YACZ;AAAA,mDAAC,SAAI,MAAK,cAAa,cAAW,cAAa,OAAO,YAAY,WAChE,iBAAO,IAAI,CAAC,OAAO,UAAU;AAC7B,cAAM,WAAW,MAAM,QAAQ;AAC/B;AAAA;AAAA;AAAA;AAAA,UAIC;AAAA,YAAC;AAAA;AAAA,cAEA,MAAK;AAAA,cACL,KAAK,CAAC,OAAiC;AACtC,qBAAK,QAAQ,KAAK,IAAI;AAAA,cACvB;AAAA,cACA,MAAK;AAAA,cACL,gBAAc;AAAA,cAOd,UAAU,YAAa,CAAC,OAAO,KAAK,CAAC,MAAM,EAAE,QAAQ,KAAK,KAAK,UAAU,IAAK,IAAI;AAAA,cAClF,OAAO,0BAA0B,MAAM,IAAI;AAAA,cAC3C,OAAO,YAAY,QAAQ;AAAA,cAC3B,SAAS,MAAM;AACd,8BAAc,KAAK;AACnB,yBAAS,MAAM,GAAG;AAAA,cACnB;AAAA,cAEC,gBAAM;AAAA;AAAA,YArBF,MAAM;AAAA,UAsBZ;AAAA;AAAA,MAEF,CAAC,GAEF;AAAA,MAKC;AAAA,QAAC;AAAA;AAAA,UACA,MAAK;AAAA,UACL,OAAO,YAAY,UAAU,QAAQ;AAAA,UACrC,iBAAe;AAAA,UACf,SAAS,MAAM,cAAc,CAAC,SAAS,CAAC,IAAI;AAAA,UAE3C,oBAAU,WAAW,GAAG,OAAO,KAAK,OAAO,OAAO,GAAG,KAAK;AAAA;AAAA,MAC5D;AAAA,OACD;AAAA,IAEC,cACA,8CAAC,SAAI,OAAO,WACX;AAAA,oDAAC,WAAM,OAAO,aACb;AAAA,qDAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,kBAAI;AAAA,QACzB;AAAA,UAAC;AAAA;AAAA,YACA,MAAK;AAAA,YACL,OAAO,MAAM;AAAA,YACb,KAAK,MAAM,OAAO;AAAA,YAClB,OAAO;AAAA,YACP,UAAU,CAAC,MAAM,SAAS,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,EAAE,cAAc,MAAM,EAAE;AAAA;AAAA,QAC1E;AAAA,SACD;AAAA,MACA,8CAAC,WAAM,OAAO,aACb;AAAA,qDAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,gBAAE;AAAA,QACvB;AAAA,UAAC;AAAA;AAAA,YACA,MAAK;AAAA,YACL,OAAO,MAAM;AAAA,YACb,KAAK,MAAM,SAAS;AAAA,YACpB,OAAO;AAAA,YACP,UAAU,CAAC,MAAM,SAAS,CAAC,OAAO,EAAE,GAAG,GAAG,KAAK,EAAE,cAAc,MAAM,EAAE;AAAA;AAAA,QACxE;AAAA,SACD;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACA,MAAK;AAAA,UACL,OAAO,YAAY,IAAI;AAAA,UAGvB,UAAU,CAAC,MAAM,SAAS,CAAC,MAAM;AAAA,UACjC,SAAS,MAAM;AAAE,2BAAe,KAAK;AAAG,qBAAS,QAAQ;AAAA,UAAE;AAAA,UAC3D;AAAA;AAAA,MAED;AAAA,OACD;AAAA,KAEF;AAEF;AAGA,IAAM,eAA2C;AAAA,EAChD,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,WAAW;AACZ;AAYA,SAAS,gBAAgB,EAAE,QAAQ,GAAsF;AACxH,QAAM,WAAY,OAAO,QAAQ,OAAO,EAAwC,OAAO,CAAC,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,IAAI;AACnH,MAAI,SAAS,WAAW,EAAG,QAAO;AAElC,SACC,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,WAAU,QAAM,MACjD,wDAAC,iCAAK,KAAK,GAAG,OAAM,UAAS,MAAK,QACjC;AAAA,iDAAC,iCAAK,MAAM,GAAG,QAAO,YAAW,8BAAgB;AAAA,IAChD,SAAS,IAAI,CAAC,CAAC,MAAM,MAAM,MAC3B,8CAAC,iCAAgB,KAAK,GAAG,OAAM,UAC9B;AAAA,mDAAC,kCAAM,MAAM,OAAO,WAAW,UAAU,aAAa,WAAW,UAAU,GACzE,uBAAa,IAAI,GACnB;AAAA,MACA,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAClB,iBAAO,WAAW,UAAU,OAAO,UAAU,gCAC/C;AAAA,SANU,IAOX,CACA;AAAA,KACF,GACD;AAEF;AASA,SAAS,UAAU,EAAE,OAAO,SAAS,GAA4E;AAChH,QAAM,WAAO,sBAAwC,CAAC,CAAC;AAEvD,QAAM,gBAAY;AAAA,IACjB,CAAC,UAA+B;AAC/B,YAAM,eAAe,OAAO,UAAU,CAAC,MAAM,EAAE,OAAO,KAAK;AAC3D,UAAI,YAA2B;AAE/B,UAAI,MAAM,QAAQ,aAAc,cAAa,eAAe,KAAK,OAAO;AACxE,UAAI,MAAM,QAAQ,YAAa,cAAa,eAAe,IAAI,OAAO,UAAU,OAAO;AACvF,UAAI,MAAM,QAAQ,OAAQ,aAAY;AACtC,UAAI,MAAM,QAAQ,MAAO,aAAY,OAAO,SAAS;AAErD,UAAI,cAAc,KAAM;AAExB,YAAM,eAAe;AACrB,YAAM,OAAO,OAAO,SAAS;AAC7B,UAAI,CAAC,KAAM;AAEX,eAAS,KAAK,EAAE;AAChB,WAAK,QAAQ,SAAS,GAAG,MAAM;AAAA,IAChC;AAAA,IACA,CAAC,OAAO,QAAQ;AAAA,EACjB;AAEA,SACC,6CAAC,SAAI,MAAK,WAAU,cAAW,UAAS,OAAO,UAAU,WACvD,iBAAO,IAAI,CAAC,OAAO,UAAU;AAC7B,UAAM,WAAW,MAAM,OAAO;AAC9B;AAAA;AAAA;AAAA;AAAA,MAIC;AAAA,QAAC;AAAA;AAAA,UAEA,MAAK;AAAA,UACL,KAAK,CAAC,OAAiC;AACtC,iBAAK,QAAQ,KAAK,IAAI;AAAA,UACvB;AAAA,UACA,IAAI,OAAO,MAAM,EAAE;AAAA,UACnB,MAAK;AAAA,UACL,iBAAe;AAAA,UACf,iBAAe,SAAS,MAAM,EAAE;AAAA,UAChC,UAAU,WAAW,IAAI;AAAA,UACzB,OAAO,SAAS,QAAQ;AAAA,UACxB,SAAS,MAAM,SAAS,MAAM,EAAE;AAAA,UAE/B,gBAAM;AAAA;AAAA,QAbF,MAAM;AAAA,MAcZ;AAAA;AAAA,EAEF,CAAC,GACF;AAEF;AAeA,SAAS,eAAe,EAAE,MAAM,GAAwD;AAWvF,MAAI,UAAU,MAAM;AACnB,WACC,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,iMAGrB;AAAA,EAEF;AAIA,MAAI,QAAQ,IAAK,QAAO;AAExB,QAAM,OAAO,KAAK,OAAO,IAAI,SAAS,GAAG;AAKzC,QAAM,aAAa,OAAO,IAAI,KAAK,MAAM,MAAM,IAAI,IAAI;AACvD,SACC,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAM,QAAQ,MAAM,aAAa,WAAW,QAAM,MAS9E,wDAAC,iCAAK,MAAM,GAAG;AAAA;AAAA,IACoB;AAAA,IAAK;AAAA,IAC3B,eAAe,OAAO,eAAe,GAAG,UAAU;AAAA,IAAI;AAAA,KAEnE,GACD;AAEF;AAWO,SAAS,YAAY;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD,GAWuB;AACtB,SACG,8CAAC,kCAAM,OAAO,GAYb;AAAA,iDAAC,mBAAgB,SAAS,SAAS,SAAS;AAAA,IAC3C,qBACA,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,WAAU,QAAM,MACjD,wDAAC,iCAAK,MAAM,GAAG;AAAA;AAAA,MACwD;AAAA,OACvE,GACD;AAAA,IAWA,SAAS,cAAc,cAAc,SAAS,KAAK,KACnD,8CAAC,iCAAK,MAAM,GAAG,OAAO,EAAE,OAAO,gBAAgB,GAAG;AAAA;AAAA,MAC5B,SAAS,WAAW,MAAM;AAAA,MAAM;AAAA,MAAK,SAAS,WAAW,MAAM;AAAA,MAAI;AAAA,MAKvF,SAAS,WAAW,UAAU,0BAC5B,qCACA;AAAA,MACF,SAAS,WAAW,eACpB,6EAAE,kGAAqF;AAAA,OAEzF;AAAA,IAGA,cAAc,SAAS,KAAK,KAC5B,6CAAC,kBAAe,OAAO,eAAe,YAAY,OAAO,UAAU,WAAW,SAAS,MAAS,GAAG;AAAA,IAGnG,UAAU,cACV,8CAAC,SAAI,OAAO,YACX;AAAA,mDAAC,iBAAc,MAAM,SAAS,MAAe,UAAU,SAAS,YAAY,MAAe,SAAkB;AAAA,MAQ7G;AAAA,QAAC;AAAA;AAAA,UACA,OAAM;AAAA,UACN,MAAK;AAAA,UACL,UAAU,cAAc,SAAS,IAAa;AAAA,UAC9C,aAAW;AAAA,UACX,aAAa;AAAA,UAEb;AAAA;AAAA,cAAC;AAAA;AAAA,gBACA,MAAM,SAAS;AAAA,gBACf,aAAa,YAAY,WAAW,UAAW,YAAY,SAAS,OAAiB;AAAA;AAAA,YACtF;AAAA,YAIC,YAAY,WAAW,aAAa,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,0CAAuB;AAAA,YAChF,YAAY,WAAW,WACvB,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,WAAU,QAAM,MACjD,wDAAC,iCAAK,MAAM,GAAG;AAAA;AAAA,cAAqC,YAAY;AAAA,eAAQ,GACzE;AAAA;AAAA;AAAA,MAEF;AAAA,OACD;AAAA,IAEA,UAAU,iBAAiB,6CAAC,oBAAiB,MAAM,SAAS,MAAe,UAAU,SAAS,YAAY,MAAe;AAAA,IACzH,UAAU,aAAa,6CAAC,gBAAa,MAAM,SAAS,MAAe;AAAA,IACnE,UAAU,uBAAuB,6CAAC,yBAAsB,MAAM,SAAS,MAAe;AAAA,IAKvF,6CAAC,cAAW,SAAS,SAAS,SAAS;AAAA,IAEvC,8CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC;AAAA;AAAA,MACL,SAAS,MAAM;AAAA,MAAM;AAAA,MAAK,SAAS,MAAM;AAAA,MAAI;AAAA,MAAM,SAAS,MAAM;AAAA,OAClF;AAAA,KACD;AAEJ;AASA,SAAS,cAAc;AAAA,EACtB;AAAA,EACA;AACD,GAGuB;AAItB,SACC,8CAAC,SAAI,OAAO,YAAY,MAAK,SAAQ,cAAW,oBAG/C;AAAA,iDAAC,iCAAK,MAAM,GAAG,OAAO,EAAE,OAAO,gBAAgB,GAAG,2BAAa;AAAA,IAG/D;AAAA,MAAC;AAAA;AAAA,QACA,MAAK;AAAA,QACL,OAAO,YAAY,UAAU,iBAAiB;AAAA,QAC9C,gBAAc,UAAU;AAAA,QACxB,OAAM;AAAA,QACN,SAAS,MAAM,SAAS,iBAAiB;AAAA,QACzC;AAAA;AAAA,IAED;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACA,MAAK;AAAA,QACL,OAAO,YAAY,UAAU,uBAAuB;AAAA,QACpD,gBAAc,UAAU;AAAA,QACxB,OAAM;AAAA,QACN,SAAS,MAAM,SAAS,uBAAuB;AAAA,QAC/C;AAAA;AAAA,IAED;AAAA,KACD;AAEF;AAYA,SAAS,cAAc,MAA8D;AACpF,QAAM,YAAY,MAAM;AACxB,MAAI,cAAc,QAAQ,cAAc,UAAa,CAAC,OAAO,SAAS,SAAS,GAAG;AACjF,WAAO;AAAA,EACR;AACA,QAAM,SAAS,KAAK,OAAO,IAAI,aAAa,GAAG;AAC/C,SAAO,aAAa,IACjB,oCAAoC,MAAM,uDAC1C;AACJ;AAGA,SAAS,YAAY;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD,GASuB;AACtB,QAAM,EAAE,OAAO,OAAO,IAAI,UAAmB,EAAE,YAAY,QAAQ,OAAO,QAAQ,QAAQ,CAAC;AAM3F,QAAM,cAAc,UAAmB;AAAA,IACtC;AAAA,IACA,QAAQ;AAAA,IACR,OAAO;AAAA,IACP,SAAS,UAAU;AAAA,EACpB,CAAC,EAAE;AAIH,QAAM,cACL,MAAM,WAAW,YACd,mBACA,MAAM,WAAW,UAChB,GAAG,OAAO,KAAK,CAAC,MAAM,EAAE,OAAO,KAAK,GAAG,SAAS,KAAK,gBAAgB,UAAU,WAAW,GAAG,OAAO,KAAK,OAAO,OAAO,GAAG,KAAK,gBAAgB,KAAK,EAAE,KACtJ,MAAM,WAAW,UAChB,MAAM,WACL,mDACA,kBAAkB,MAAM,OAAO,KAChC;AAEN,SACC,8CAAC,kCAAM,OAAO,GACb;AAAA,iDAAC,gCAAI,aAAU,UAAS,OAAO,EAAE,UAAU,YAAY,OAAO,GAAG,QAAQ,GAAG,UAAU,UAAU,MAAM,gBAAgB,GACpH,uBACF;AAAA,IAEC,MAAM,WAAW,WAAW,MAAM;AAAA;AAAA;AAAA;AAAA,IAKlC,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,4BAAS;AAAA,IAG9B,MAAM,WAAW,aACjB,8CAAC,iCAAK,OAAM,UAAS,KAAK,GAAG,SAAS,GACrC;AAAA,mDAAC,oCAAQ,OAAK,MAAC;AAAA,MACf,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,2BAAQ;AAAA,OAC9B;AAAA,IAGA,MAAM,WAAW;AAAA;AAAA;AAAA,IAIjB,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAM,MAAM,WAAW,gBAAgB,YAAY,QAAM,MACrF,wDAAC,kCAAM,OAAO,GACZ;AAAA,YAAM,YAAY,6CAAC,iCAAK,MAAM,GAAG,QAAO,UAAS,0BAAY;AAAA,MAC9D,6CAAC,iCAAK,MAAM,GAAG,OAAO,MAAM,UAAW,gBAAM,SAAQ;AAAA,MACpD,CAAC,MAAM,YACP,6CAAC,gCAMA,uDAAC,YAAO,MAAK,UAAS,OAAO,YAAY,KAAK,GAAG,SAAS,QAAQ,uBAAS,GAC5E;AAAA,OAEF,GACD;AAAA,IAGA,MAAM,WAAW,WACjB;AAAA,MAAC;AAAA;AAAA,QACA,UAAU,MAAM;AAAA,QAChB;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,mBAAmB,MAAM,qBAAqB;AAAA,QAC9C;AAAA,QACA;AAAA;AAAA,IACD;AAAA,KAEF;AAEF;AAOA,SAAS,WAAW,MAAsB;AAGzC,QAAM,MAAM,oBAAI,KAAK;AACrB,QAAM,cAAc,KAAK,IAAI,IAAI,eAAe,GAAG,IAAI,YAAY,GAAG,IAAI,WAAW,CAAC;AACtF,SAAO,IAAI,KAAK,cAAc,OAAO,KAAU,EAAE,YAAY,EAAE,MAAM,GAAG,EAAE;AAC3E;AASA,IAAM,gBAAgB,CAAC,YAAY,aAAa;AAShD,IAAM,gBAAN,cAA4B,cAAAC,QAAM,UAGhC;AAAA,EACD,YAAY,OAAwD;AACnE,UAAM,KAAK;AACX,SAAK,QAAQ,EAAE,OAAO,MAAM,UAAU,MAAM,SAAS;AAAA,EACtD;AAAA,EAEA,OAAO,yBAAyB,OAAc;AAC7C,WAAO,EAAE,MAAM;AAAA,EAChB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,OAAO,yBACN,OACA,OACC;AACD,QAAI,MAAM,aAAa,MAAM,SAAU,QAAO,EAAE,OAAO,MAAM,UAAU,MAAM,SAAS;AACtF,WAAO;AAAA,EACR;AAAA,EAEA,kBAAkB,OAAc;AAC/B,YAAQ,MAAM,0CAA0C,MAAM,OAAO;AAAA,EACtE;AAAA,EAEA,SAAS;AACR,QAAI,CAAC,KAAK,MAAM,MAAO,QAAO,KAAK,MAAM;AACzC,WACC,6CAAC,iCAAK,SAAS,GAAG,QAAQ,GAAG,MAAK,YAAW,QAAM,MAClD,wDAAC,kCAAM,OAAO,GACb;AAAA,mDAAC,iCAAK,MAAM,GAAG,QAAO,UAAS,2CAA6B;AAAA,MAC5D,6CAAC,iCAAK,MAAM,GAAG,mLAGf;AAAA,MACA,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAE,eAAK,MAAM,MAAM,SAAQ;AAAA,MAO/C,6CAAC,iCAAK,MAAM,GAAG,OAAK,MAAC,wEAA0D;AAAA,OAChF,GACD;AAAA,EAEF;AACD;AAKO,SAAS,oBAAoB,OAA8D;AAGjG,QAAM,aAAa,MAAM,MAAM,SAAS,cAAc,MAAM,cAAc;AAE1E,QAAM,YAAY,MAAM,MAAM,SAAS,aAAa,MAAM,aAAa;AASvE,QAAM,CAAC,MAAM,QAAI,wBAAoB,MAAO,OAAO,WAAW,cAAc,CAAC,IAAI,WAAW,OAAO,SAAS,IAAI,CAAE;AAGlH,QAAM,cAAc,OAAO,KAAK,CAAC,MAAM,EAAE,QAAQ,OAAO,KAAK,IAAK,OAAO,QAAqB;AAC9F,QAAM,YAAY,OAAO,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO,GAAG,IAAK,OAAO,MAAiB;AAWrF,QAAM,CAAC,OAAO,QAAQ,QAAI,wBAAmB,eAAe,OAAO;AAInE,QAAM,CAAC,kBAAkB,mBAAmB,QAAI,wBAAmB,eAAe,OAAO;AAIzF,QAAM,CAAC,QAAQ,SAAS,QAAI,wBAAS,MAAO,OAAO,QAAQ,OAAO,KAC/D,EAAE,OAAO,OAAO,MAAM,KAAK,OAAO,GAAG,IACrC,EAAE,OAAO,WAAW,EAAE,GAAG,KAAK,WAAW,CAAC,EAAE,CAAE;AAEjD,QAAM,YAAY,OAAO,OAAO,WAAW,CAAC;AAO5C,QAAM,CAAC,SAAS,UAAU,QAAI,wBAAsD,iBAAiB;AAErG,QAAM,CAAC,aAAa,cAAc,QAAI,wBAAiB,aAAa,UAAU;AAQ9E,gBAAAC,QAAM,UAAU,MAAM;AACrB,QAAI,OAAO,WAAW,YAAa;AACnC,UAAM,OAAO,cAAc,OAAO,SAAS,MAAM;AAAA,MAChD,KAAK;AAAA,MACL;AAAA,MACA,MAAM,OAAO;AAAA,MACb,IAAI,OAAO;AAAA,IACZ,CAAC;AACD,QAAI,SAAS,OAAO,SAAS,MAAM;AAClC,aAAO,QAAQ,aAAa,MAAM,IAAI,GAAG,OAAO,SAAS,QAAQ,GAAG,OAAO,SAAS,MAAM,GAAG,IAAI,EAAE;AAAA,IACpG;AAAA,EACD,GAAG,CAAC,aAAa,OAAO,OAAO,OAAO,OAAO,GAAG,CAAC;AAEjD,QAAM,SAAS,OAAO,KAAK,CAAC,MAAM,EAAE,OAAO,WAAW,KAAK,OAAO,CAAC;AAEnE;AAAA;AAAA;AAAA;AAAA,IAIC,8CAAC,sCAAU,OAAO,GAAG,SAAS,GAAG,IAAG,WAAU,cAAY,YAAY,wBAAwB,SAAS,KAAK,oBAI3G;AAAA,mDAAC,WAAO,4BAAiB;AAAA,MACzB,8CAAC,kCAAM,OAAO,GACb;AAAA,sDAAC,iCAAK,OAAM,cAAa,SAAQ,iBAAgB,KAAK,GAAG,MAAK,QAI7D;AAAA,uDAAC,oCAAQ,MAAM,GAAG,8BAAgB;AAAA,UAClC,8CAAC,kCAAM,OAAO,GACb;AAAA,yDAAC,iBAAc,OAAO,OAAO,QAAgB,UAAU,UAAU,gBAAgB,WAAW;AAAA,YAO3F,cAAc,SAAS,WAAW,KAAK,6CAAC,iBAAc,OAAO,SAAS,UAAU,YAAY;AAAA,aAC9F;AAAA,WACD;AAAA,QAEA,6CAAC,aAAU,OAAO,aAAa,UAAU,gBAAgB;AAAA,QAKxD,UAAU,YACV,8CAAC,SAAI,OAAO,YACX;AAAA,wDAAC,iCAAK,MAAM,GAAG,OAAK,MAEE;AAAA;AAAA,YACZ,OAAO;AAAA,YAAM;AAAA,YAAK,OAAO;AAAA,YAAI;AAAA,YAAG,YAAY,OAAO,OAAO,OAAO,GAAG;AAAA,YAAE;AAAA,YAC9E,YAAY,OAAO,OAAO,OAAO,GAAG,IAAI,MACxC,6EAAE,2HACiC;AAAA,aAErC;AAAA,UAMA;AAAA,YAAC;AAAA;AAAA,cACA,MAAK;AAAA,cACL,OAAO;AAAA,cACP,cAAW;AAAA,cACX,SAAS,MAAM;AACd,sBAAM,OAAO,YAAY,OAAO,OAAO,OAAO,GAAG;AACjD,0BAAU,EAAE,OAAO,UAAU,OAAO,OAAO,CAAC,IAAI,GAAG,KAAK,UAAU,OAAO,KAAK,CAAC,IAAI,EAAE,CAAC;AAAA,cACvF;AAAA,cACA;AAAA;AAAA,UAED;AAAA,UAMA;AAAA,YAAC;AAAA;AAAA,cACA,MAAK;AAAA,cACL,cAAW;AAAA,cACX,UAAU;AAAA,cAKV,OAAO,YAAY,EAAE,GAAG,aAAa,SAAS,MAAM,QAAQ,cAAc,IAAI;AAAA,cAC9E,OAAO,YAAY,mCAAmC;AAAA,cACtD,SAAS,MAAM;AACd,sBAAM,OAAO,YAAY,OAAO,OAAO,OAAO,GAAG;AACjD,sBAAM,QAAQ,WAAW,CAAC;AAK1B,sBAAM,OAAO,YAAY,OAAO,KAAK,KAAK,IAAI;AAC9C,sBAAM,UAAU,KAAK,IAAI,MAAM,IAAI;AACnC,oBAAI,WAAW,EAAG;AAClB,0BAAU,EAAE,OAAO,UAAU,OAAO,OAAO,OAAO,GAAG,KAAK,UAAU,OAAO,KAAK,OAAO,EAAE,CAAC;AAAA,cAC3F;AAAA,cACA;AAAA;AAAA,UAED;AAAA,UACA,8CAAC,YAAO,MAAK,UAAS,OAAO,aAAa,SAAS,MAAM,SAAS,gBAAgB,GAAG;AAAA;AAAA,YACvE,OAAO,KAAK,CAAC,MAAM,EAAE,QAAQ,gBAAgB,GAAG,MAAM,YAAY,KAAK;AAAA,aACrF;AAAA,WACD;AAAA,QAGD;AAAA,UAAC;AAAA;AAAA,YACA,MAAK;AAAA,YACL,IAAI,SAAS,WAAW;AAAA,YACxB,mBAAiB,OAAO,WAAW;AAAA,YAEnC,UAAU;AAAA,YACV,OAAO,EAAE,UAAU,WAAW;AAAA,YAE9B,uDAAC,kCAAM,OAAO,GAIb,uDAAC,iBAAc,UAAU,GAAG,WAAW,IAAI,KAAK,IAAI,OAAO,KAAK,IAAI,OAAO,GAAG,IAC7E;AAAA,cAAC;AAAA;AAAA,gBACA,OAAO,QAAQ,MAAM;AAAA,gBACrB,QAAQ,QAAQ,UAAU;AAAA,gBAC1B;AAAA,gBACA;AAAA,gBACA;AAAA,gBACA;AAAA,gBACA,SAAS,CAAC,OAAO,QAAQ;AAAE,sBAAI,UAAU,SAAU,qBAAoB,KAAK;AAAG,4BAAU,EAAE,OAAO,IAAI,CAAC;AAAG,2BAAS,QAAQ;AAAA,gBAAE;AAAA;AAAA,YAC9H,GACD,GACD;AAAA;AAAA,QACD;AAAA,SACD;AAAA,OACD;AAAA;AAEF;;;AWxlCO,IAAM,cAAc;AAkD3B,SAAS,UAAU,SAAwD;AAC1E,MAAI,YAAY,QAAQ,YAAY,OAAW,QAAO;AACtD,MAAI,OAAO,YAAY,SAAU,QAAO,EAAE,MAAM,QAAQ;AACxD,MAAI,CAAC,QAAQ,KAAM,QAAO;AAC1B,SAAO;AACR;AAiBA,SAAS,SAAS,QAAgB,MAAqB,QAAgB,MAAuB;AAE7F,SAAO,UAAU,SAAS,SAAS,QAAQ,OAAO;AACnD;AASO,SAAS,iBACf,UACA,WACA,OACW;AACX,MAAI,EAAE,aAAa,UAAW,QAAO,EAAE,QAAQ,QAAQ,QAAQ,gBAAgB;AAE/E,QAAM,UAAU,UAAU,SAAS,SAAS,CAAC;AAC7C,MAAI,CAAC,QAAS,QAAO,EAAE,QAAQ,QAAQ,QAAQ,mBAAmB;AAGlE,MAAI;AACJ,MAAI,sBAAsB;AAE1B,MAAI,QAAQ,SAAS,aAAa;AACjC,UAAM,cAAc,KAAK,MAAM,QAAQ,IAAI;AAE3C,QAAI,OAAO,MAAM,WAAW,EAAG,QAAO,EAAE,QAAQ,QAAQ,QAAQ,gBAAgB;AAEhF,cAAU,QAAQ;AAClB,QAAI,KAAK,MAAM,MAAM,GAAG,IAAI,YAAa,QAAO,EAAE,QAAQ,QAAQ,QAAQ,kBAAkB,QAAQ;AACpG,0BAAsB,KAAK,MAAM,MAAM,KAAK,IAAI;AAAA,EACjD;AAGA,QAAM,OAAO,QAAQ,WAAW,CAAC,GAAG,OAAO,CAAC,MAAM,SAAS,EAAE,OAAO,EAAE,OAAO,MAAM,OAAO,MAAM,GAAG,CAAC;AAGpG,QAAM,YAAY,IAAI,KAAK,CAAC,MAAM,EAAE,SAAS,MAAM,UAAU,EAAE,UAAU,QAAQ,EAAE,QAAQ,MAAM,IAAI;AACrG,MAAI,UAAW,QAAO,EAAE,QAAQ,QAAQ,QAAQ,UAAU,SAAS,CAAC,SAAS,GAAG,QAAQ;AAExF,MAAI,IAAI,SAAS,EAAG,QAAO,EAAE,QAAQ,WAAW,QAAQ,gBAAgB,SAAS,KAAK,QAAQ;AAC9F,MAAI,oBAAqB,QAAO,EAAE,QAAQ,WAAW,QAAQ,iBAAiB,SAAS,CAAC,GAAG,QAAQ;AAEnG,SAAO,EAAE,QAAQ,OAAO;AACzB;;;ACsEO,SAAS,mBAAmB,QAAmE;AACrG,QAAM,WAA4B,CAAC;AAEnC,MAAI,CAAC,QAAQ;AACZ,WAAO,CAAC,EAAE,OAAO,UAAU,SAAS,0BAA0B,CAAC;AAAA,EAChE;AAEA,MAAI,CAAC,OAAO,OAAQ,UAAS,KAAK,EAAE,OAAO,UAAU,SAAS,WAAW,CAAC;AAC1E,MAAI,CAAC,OAAO,MAAO,UAAS,KAAK,EAAE,OAAO,SAAS,SAAS,WAAW,CAAC;AAExE,MAAI,OAAO,KAAK;AACf,UAAM,EAAE,YAAY,SAAS,IAAI,OAAO;AACxC,QAAI,CAAC,YAAY;AAChB,eAAS,KAAK,EAAE,OAAO,kBAAkB,SAAS,kCAAkC,CAAC;AAAA,IACtF,WAAW,WAAW,WAAW,IAAI,GAAG;AACvC,eAAS,KAAK;AAAA,QACb,OAAO;AAAA,QACP,SAAS,iCAAiC,UAAU;AAAA,MACrD,CAAC;AAAA,IACF,WAAW,CAAC,QAAQ,KAAK,UAAU,GAAG;AACrC,eAAS,KAAK,EAAE,OAAO,kBAAkB,SAAS,sCAAsC,CAAC;AAAA,IAC1F;AAEA,QAAI,CAAC,UAAU;AACd,eAAS,KAAK,EAAE,OAAO,gBAAgB,SAAS,4CAAuC,CAAC;AAAA,IACzF,WAAW,CAAC,gBAAgB,QAAQ,GAAG;AACtC,eAAS,KAAK,EAAE,OAAO,gBAAgB,SAAS,mCAAmC,QAAQ,GAAG,CAAC;AAAA,IAChG;AAAA,EACD;AAMA,QAAM,aAAa;AACnB,QAAM,YAAY,CAAC,SAAS,QAAQ,SAAS,WAAW,YAAY,UAAU,OAAO,YAAY,SAAS;AAE1G,QAAM,aAAyD;AAAA,IAC9D,CAAC,sBAAsB,OAAO,QAAQ,WAAW;AAAA,IACjD,CAAC,qBAAqB,OAAO,QAAQ,UAAU;AAAA,IAC/C,CAAC,yBAAyB,OAAO,QAAQ,cAAc;AAAA,EACxD;AAEA,aAAW,QAAQ,OAAO,KAAK,OAAO,QAAQ,iBAAiB,CAAC,CAAC,GAAG;AACnE,eAAW,KAAK,CAAC,wBAAwB,IAAI,IAAI,IAAI,CAAC;AAAA,EACvD;AAEA,aAAW,CAAC,OAAO,KAAK,KAAK,YAAY;AACxC,QAAI,CAAC,MAAO;AACZ,QAAI,CAAC,WAAW,KAAK,KAAK,GAAG;AAC5B,eAAS,KAAK,EAAE,OAAO,SAAS,0DAA0D,KAAK,GAAG,CAAC;AACnG;AAAA,IACD;AACA,UAAM,UAAU,MAAM,YAAY;AAClC,UAAM,OAAO,UAAU,KAAK,CAAC,SAAS,QAAQ,SAAS,IAAI,CAAC;AAC5D,QAAI,MAAM;AACT,eAAS,KAAK;AAAA,QACb;AAAA,QACA,SAAS,8CAA8C,IAAI;AAAA,MAC5D,CAAC;AAAA,IACF;AAAA,EACD;AAIA,MAAI,OAAO,QAAQ,cAAc,CAAC,OAAO,OAAO,UAAU;AACzD,aAAS,KAAK;AAAA,MACb,OAAO;AAAA,MACP,SAAS;AAAA,IACV,CAAC;AAAA,EACF;AAIA,aAAW,CAAC,SAAS,KAAK,KAAK,OAAO,QAAQ,OAAO,cAAc,CAAC,CAAC,GAAG;AACvE,eAAW,QAAQ,SAAS,CAAC,GAAG;AAC/B,UAAI,OAAO,iBAAiB,EAAE,QAAQ,OAAO,gBAAgB;AAC5D,iBAAS,KAAK;AAAA,UACb,OAAO,cAAc,OAAO;AAAA,UAC5B,SAAS,IAAI,IAAI;AAAA,QAClB,CAAC;AAAA,MACF;AAAA,IACD;AAAA,EACD;AAEA,aAAW,QAAQ,OAAO,KAAK,aAAa,CAAC,GAAG;AAG/C,QAAI,eAAe,KAAK,IAAI,KAAK,KAAK,SAAS,GAAG,GAAG;AACpD,eAAS,KAAK,EAAE,OAAO,iBAAiB,SAAS,uCAAuC,IAAI,GAAG,CAAC;AAAA,IACjG;AAAA,EACD;AAEA,MAAI,OAAO,QAAQ,mBAAmB,OAAO,OAAO,gBAAgB,WAAW,GAAG;AACjF,aAAS,KAAK;AAAA,MACb,OAAO;AAAA,MACP,SAAS;AAAA,IACV,CAAC;AAAA,EACF;AAEA,MAAI,OAAO,UAAU,CAAC,OAAO,OAAO,WAAW;AAC9C,aAAS,KAAK,EAAE,OAAO,oBAAoB,SAAS,qCAAqC,CAAC;AAAA,EAC3F;AAEA,MAAI,CAAC,OAAO,QAAQ,cAAc;AACjC,aAAS,KAAK,EAAE,OAAO,uBAAuB,SAAS,WAAW,CAAC;AAAA,EACpE;AAEA,MAAI,CAAC,OAAO,eAAe;AAC1B,aAAS,KAAK,EAAE,OAAO,iBAAiB,SAAS,qDAAgD,CAAC;AAAA,EACnG;AAEA,aAAW,UAAU,OAAO,wBAAwB,CAAC,GAAG;AACvD,QAAI,CAAC,eAAe,KAAK,MAAM,GAAG;AACjC,eAAS,KAAK,EAAE,OAAO,wBAAwB,SAAS,2CAA2C,MAAM,GAAG,CAAC;AAAA,IAC9G;AACA,QAAI,OAAO,SAAS,GAAG,GAAG;AACzB,eAAS,KAAK,EAAE,OAAO,wBAAwB,SAAS,mCAAmC,MAAM,GAAG,CAAC;AAAA,IACtG;AAAA,EACD;AAEA,SAAO;AACR;AAGO,SAAS,gBAAgB,UAA2B;AAC1D,MAAI;AACH,QAAI,KAAK,eAAe,SAAS,EAAE,SAAS,CAAC;AAC7C,WAAO;AAAA,EACR,QAAQ;AACP,WAAO;AAAA,EACR;AACD;;;Ab3UA,IAAM,gBAAY,0BAAY,mBAAmB,cAAc;AAqCxD,IAAM,sBAAkB,6BAA2C,CAAC,YAAY;AACtF,QAAM,EAAE,YAAY,WAAW,OAAO,oBAAoB,QAAQ,YAAY,MAAM,IAAI;AAExF,SAAO;AAAA,IACN,MAAM;AAAA,IACN,OAAO,CAAC,MAAM,EAAE,YAAY,MAAM;AACjC,UAAI,SAAS,MAAM,SAAS,GAAG;AAC9B,cAAM,YAAY,aAAa,OAAO,IAAI,CAAC,SAAS,KAAK,IAAI,KAAK,CAAC;AACnE,cAAM,YAAY,UAAU,KAAK,CAAC,SAAS,MAAM,SAAS,IAAI,CAAC;AAC/D,YAAI,CAAC,UAAW,QAAO;AAAA,MACxB;AAEA,aAAO;AAAA,QACN,GAAG;AAAA,QACH;AAAA,UACC;AAAA,UACA;AAAA,UACA,MAAM;AAAA,UACN,WAAW;AAAA,UACX,SAAS,EAAE,YAAY,UAAU;AAAA,QAClC;AAAA,MACD;AAAA,IACD;AAAA,EACD;AACD,CAAC;AAED,IAAO,gBAAQ;","names":["import_sanity","import_react","import_sanity_ui_compat","import_react","React","unavailable","rows","import_sanity_ui_compat","import_sanity_ui_compat","import_react","import_sanity_ui_compat","import_jsx_runtime","React","d3Max","d3Line","import_jsx_runtime","import_jsx_runtime","React","React","React"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/studio/VisitorInsightsTool.tsx","../src/studio/useReport.ts","../src/studio/urlState.ts","../src/core/ranges.ts","../src/studio/Figure.tsx","../src/studio/palette.ts","../src/types.ts","../src/studio/panels.tsx","../src/studio/CrossSourceTimeline.tsx","../src/core/rank.ts","../src/core/capture.ts","../src/core/cutover.ts","../src/core/siteConfig.ts"],"sourcesContent":["/**\n * Studio entry point — browser only.\n *\n * Must never import from `./server` or anything under `src/server/`, which reads credentials and\n * uses node:crypto. The split export subpaths are what keep that guarantee enforceable.\n */\n\nimport { definePlugin } from 'sanity'\nimport { resolveIcon } from '@liiift-studio/sanity-ui-compat/icons'\nimport { VisitorInsightsTool, type VisitorInsightsToolProps } from './studio/VisitorInsightsTool'\n\n/** Chart glyph, resolved against whichever @sanity/icons major the Studio has installed. */\nconst ChartIcon = resolveIcon('ChartUpwardIcon', 'chart-upward')\n\n/** Options for the Studio plugin. */\nexport interface VisitorInsightsPluginOptions {\n\t/**\n\t * Base URL of the site serving the reports, e.g. `https://dardenstudio.com`.\n\t * Same-origin studios can pass an empty string.\n\t */\n\tapiBaseUrl: string\n\t/** Label shown in the tool header. */\n\tsiteLabel: string\n\t/** Tool name in the Studio URL. Defaults to `visitor-insights`. */\n\tname?: string\n\t/** Title in the Studio nav. Defaults to `Insights`. */\n\ttitle?: string\n\t/**\n\t * Restrict the tool to these Sanity roles. Omit to show it to every Studio user.\n\t * These panels read order-derived conversion figures, so gating to administrators is\n\t * usually right — matching how the deploy and utilities tools are already gated.\n\t */\n\troles?: string[]\n}\n\n/**\n * Visitor Insights — visitor-behaviour analytics inside the Studio.\n *\n * @example\n * ```ts\n * plugins: [\n * visitorInsights({\n * apiBaseUrl: 'https://dardenstudio.com',\n * siteLabel: 'Darden Studio',\n * roles: ['administrator'],\n * }),\n * ]\n * ```\n */\nexport const visitorInsights = definePlugin<VisitorInsightsPluginOptions>((options) => {\n\tconst { apiBaseUrl, siteLabel, name = 'visitor-insights', title = 'Insights', roles } = options\n\n\treturn {\n\t\tname: '@liiift-studio/sanity-visitor-insights',\n\t\ttools: (prev, { currentUser }) => {\n\t\t\tif (roles && roles.length > 0) {\n\t\t\t\tconst userRoles = currentUser?.roles?.map((role) => role.name) ?? []\n\t\t\t\tconst permitted = userRoles.some((role) => roles.includes(role))\n\t\t\t\tif (!permitted) return prev\n\t\t\t}\n\n\t\t\treturn [\n\t\t\t\t...prev,\n\t\t\t\t{\n\t\t\t\t\tname,\n\t\t\t\t\ttitle,\n\t\t\t\t\ticon: ChartIcon,\n\t\t\t\t\tcomponent: VisitorInsightsTool,\n\t\t\t\t\toptions: { apiBaseUrl, siteLabel } satisfies VisitorInsightsToolProps,\n\t\t\t\t},\n\t\t\t]\n\t\t},\n\t}\n})\n\nexport default visitorInsights\n\nexport { VisitorInsightsTool, type VisitorInsightsToolProps } from './studio/VisitorInsightsTool'\nexport { knownShortfall, useReport, type ReportState, type UseReportOptions } from './studio/useReport'\nexport { decodeView, encodeView, mergeIntoHash, type ViewState } from './studio/urlState'\nexport { MetricFigure, Delta, FunnelChart, NoticeList, SortableTable, formatCount, formatMoney, formatPercent } from './studio/Figure'\nexport { ChartData, ProportionChart } from './studio/Figure'\nexport type { ChartDataProps, DeltaProps, ProportionBar, ProportionChartProps, SortColumn, SortableTableProps } from './studio/Figure'\nexport { CrossSourceTimeline } from './studio/CrossSourceTimeline'\nexport type { CrossSourceTimelineProps, Series, SeriesPoint, TimelineMarker } from './studio/CrossSourceTimeline'\nexport {\n\tAcquisitionPanel,\n\tDiagnosticsPanel,\n\tJourneyPanel,\n\tOverviewPanel,\n\tDataHealthPanel,\n\tTypefaceInterestPanel,\n} from './studio/panels'\n\n/**\n * @deprecated Renamed to `DataHealthPanel` in 0.22.0, when the panel was split into a business half\n * (`OverviewPanel`) and an instrument half. Kept as an alias because removing a public export in a\n * minor is a breaking change, and three sites upgrade this package separately.\n */\nexport { DataHealthPanel as MeasurementHealthPanel } from './studio/panels'\n\n// Shared contract types, safe in the browser — no server code reachable from here.\nexport * from './types'\nexport { PREEXISTING, coverageForRange, type EventCutover, type Coverage } from './core/cutover'\nexport { validateSiteConfig, type SiteAnalyticsConfig } from './core/siteConfig'\nexport { resolveRange, previousRange } from './core/ranges'\n// The response shapes. A consumer importing a panel could not name the type of the data it takes,\n// which is exactly the code that needs these types when the Studio and the route are version-skewed.\nexport type * from './reportData'\nexport type { CaptureBasis, CaptureEstimate, CaptureModel } from './core/capture'\n","/**\n * The Visitor Insights Studio tool.\n *\n * One range control drives every panel, implemented once here as a proper radiogroup with\n * roving tabindex — repeating a bespoke toggle per panel is how keyboard behaviour ends up\n * inconsistent between them.\n */\n\nimport React, { useCallback, useRef, useState } from 'react'\nimport { Box, Button, Card, Container, Flex, Heading, Spinner, Stack, Text } from '@liiift-studio/sanity-ui-compat'\nimport type { RangeKey, ReportName, SourceName, SourceStatus } from '../types'\nimport { knownShortfall, useReport, type ReportState } from './useReport'\nimport { decodeView, mergeIntoHash, type ViewState } from './urlState'\nimport type { ReportEnvelope } from '../types'\nimport { daysBetween, shiftDays } from '../core/ranges'\nimport { NoticeList, Section, panelStack } from './Figure'\nimport { COMPARISON, COMPARISON_STYLE, COMPARISON_TEXT, withAlpha } from './palette'\nimport { Badge } from '@liiift-studio/sanity-ui-compat'\nimport { AcquisitionPanel, DataHealthPanel, JourneyPanel, OverviewPanel, TypefaceInterestPanel } from './panels'\n\n/**\n * Control rows — range buttons, panel tabs, source status.\n *\n * Real CSS rather than the UI kit's Flex: when the compat shim cannot resolve Flex it renders a\n * plain div and a `gap` token does nothing, which is what put the Caveat badge on top of its own\n * text. Wrapping matters here too, since a Studio pane is resizable and five tabs do not fit a\n * narrow one.\n */\nconst controlRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: 6,\n\talignItems: 'center',\n\tflexWrap: 'wrap',\n}\n\n/**\n * Tab strip.\n *\n * The panels were five loose buttons in a row, which read as five actions rather than one choice\n * between five views — nothing said \"these are tabs\" except the ARIA role, which only a screen\n * reader ever heard. A shared baseline with the active tab underlined is the convention a reader\n * already knows, and it survives the compat shim falling back to plain elements because it is CSS\n * rather than a component variant.\n */\nconst tabStrip: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: 2,\n\talignItems: 'stretch',\n\tflexWrap: 'wrap',\n\tborderBottom: '1px solid var(--card-border-color, rgba(128,128,128,0.25))',\n}\n\n/** One tab. The selected state carries an underline as well as weight, never colour alone. */\nfunction tabStyle(selected: boolean): React.CSSProperties {\n\treturn {\n\t\tappearance: 'none',\n\t\tbackground: 'transparent',\n\t\tborder: 'none',\n\t\tborderBottom: `2px solid ${selected ? 'currentColor' : 'transparent'}`,\n\t\tcolor: 'inherit',\n\t\topacity: selected ? 1 : 0.62,\n\t\tfont: 'inherit',\n\t\tfontWeight: selected ? 600 : 400,\n\t\tpadding: '8px 12px',\n\t\tmarginBottom: -1,\n\t\tcursor: 'pointer',\n\t\twhiteSpace: 'nowrap',\n\t}\n}\n\n/** The context line under the tabs, saying what the panels are currently narrowed to. */\nconst contextRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: 12,\n\talignItems: 'baseline',\n\tflexWrap: 'wrap',\n}\n\n/** Clearing the narrowed span. Text, not a button-looking control — it is an undo, not an action. */\nconst inlineClear: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: 'none',\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: '0.8em',\n\topacity: 0.75,\n\tpadding: 0,\n\ttextDecoration: 'underline',\n\ttextUnderlineOffset: 3,\n\tcursor: 'pointer',\n}\n\n/** One range button. Selected state carries weight and a border, never colour alone. */\nfunction rangeButton(selected: boolean): React.CSSProperties {\n\treturn {\n\t\tappearance: 'none',\n\t\tbackground: 'transparent',\n\t\tborder: `1px solid ${selected ? 'currentColor' : 'var(--card-border-color, rgba(128,128,128,0.3))'}`,\n\t\tborderRadius: 3,\n\t\tcolor: 'inherit',\n\t\topacity: selected ? 1 : 0.7,\n\t\tfont: 'inherit',\n\t\tfontSize: '0.85em',\n\t\tfontWeight: selected ? 600 : 400,\n\t\tpadding: '5px 10px',\n\t\tcursor: 'pointer',\n\t\twhiteSpace: 'nowrap',\n\t}\n}\n\n/**\n * The comparison-basis toggle.\n *\n * Deliberately NOT `rangeButton`. It sat directly beneath the range selector in the same stack,\n * with the same border radius, weight and selected treatment, so nothing on screen connected it to\n * the figures it governs — and it read as a second row of ranges.\n *\n * Three channels separate them, only one of which is colour: a pill radius against the range row's\n * 3px, a dashed border on the unselected state, and COMPARISON on the selected one. A monochrome\n * or colour-blind reader still tells the two rows apart by shape alone.\n *\n * The tint sits well under 3:1 by design — its extent is carried by the 1px solid border, which is\n * at 4.28:1. That is the same fill-plus-boundary rule the palette registry enforces for regions.\n */\nfunction basisButton(selected: boolean): React.CSSProperties {\n\treturn {\n\t\tappearance: 'none',\n\t\tbackground: selected ? withAlpha(COMPARISON, 0.12) : 'transparent',\n\t\tborder: `1px ${selected ? 'solid' : 'dashed'} ${selected ? COMPARISON : 'var(--card-border-color, rgba(128,128,128,0.3))'}`,\n\t\tborderRadius: 999,\n\t\tcolor: selected ? COMPARISON_TEXT : 'inherit',\n\t\topacity: selected ? 1 : 0.7,\n\t\tfont: 'inherit',\n\t\tfontSize: '0.85em',\n\t\tfontWeight: selected ? 600 : 400,\n\t\tpadding: '5px 12px',\n\t\t// Pinned rather than left to emerge from padding and root font size, which is how a target\n\t\t// silently drops under the 24px minimum when someone adjusts either.\n\t\tminHeight: 24,\n\t\tcursor: 'pointer',\n\t\twhiteSpace: 'nowrap',\n\t}\n}\n\n/** The custom-range form, wrapping on a narrow pane. */\nconst pickerRow: React.CSSProperties = { display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }\n\n/** A labelled date field. */\nconst pickerField: React.CSSProperties = { display: 'grid', gap: 2 }\n\n/** Native date input, inheriting the Studio's type and colours. */\nconst dateInput: React.CSSProperties = {\n\tfont: 'inherit',\n\tfontSize: '0.85em',\n\tpadding: '4px 6px',\n\tborderRadius: 3,\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.3))',\n\tbackground: 'transparent',\n\tcolor: 'inherit',\n\tcolorScheme: 'light dark',\n}\n\n/**\n * Range options.\n *\n * Labelled by the span they actually cover rather than by a calendar word. \"Quarter\" and \"Year\"\n * were trailing day counts — 91 and 365 days ending today — not Q3 or a calendar year, and a reader\n * comparing them against anything calendar-aligned would have been comparing different things\n * without being told.\n *\n * The dates each one resolves to are NOT shown beside the buttons — only in each button's `title`\n * tooltip, which is invisible on touch and in a screenshot, and in the footer line beneath all the\n * content. An earlier version of this comment claimed otherwise; the intent was never implemented.\n */\nconst RANGES: Array<{ key: Exclude<RangeKey, 'custom'>; label: string; span: string }> = [\n\t{ key: 'week', label: 'Week', span: 'the last 7 days' },\n\t{ key: 'month', label: 'Month', span: 'the last 30 days' },\n\t{ key: 'quarter', label: 'Quarter', span: 'the last 91 days' },\n\t{ key: 'year', label: 'Year', span: 'the last 365 days' },\n]\n\n/**\n * Tabs that carry no exact figures of their own, so their numbers need the coverage ribbon.\n *\n * Overview is excluded because it computes and states the shortfall itself — a ribbon there would\n * say the same thing twice.\n *\n * Typeface interest is a half-member, and the ribbon now says so rather than claiming \"every figure\n * on this tab comes from Google Analytics\". That was false: Bought, Revenue, the licence ladder,\n * the unattributed takings and the orders carrying no amount are all read from the order book, and\n * the panel explicitly tells the reader not to scale them. The ribbon names the (GA4) marker\n * instead, which the column headers already carry.\n *\n * Four stacked doc comments used to sit here, three of them describing a tab list that no longer\n * exists — \"still five tabs\", \"Diagnostics stays last\", \"Acquisition leads\" — and none of them\n * about the array beneath. Nobody could state what this partition was, including the file.\n */\nconst GA4_ONLY_TABS = ['acquisition', 'journey', 'typeface-interest']\n\n/**\n * The tabs, in the order a reader meets them.\n *\n * Four, following the only partition that is also a real cost boundary: one tab, one report, one\n * cache entry. Overview leads because it is the only tab carrying revenue and orders, and because\n * resolving its report is what lets every other tab's ribbon state a multiplier at all.\n *\n * Journey is last: it is the only tab that can render as nothing but a caveat card, it is wholly\n * GA4, and its lower rungs are counts in the low tens. Typeface interest precedes it because it\n * carries exact order money.\n *\n * The instrument half that used to be its own tab is a folded band on Overview now, reading the\n * same report it always did — the two resolved to the same cache key, so the split was never a\n * fetch boundary.\n */\nconst PANELS: Array<{ id: string; report: ReportName; label: string }> = [\n\t{ id: 'overview', report: 'measurement-health', label: 'Overview' },\n\t{ id: 'acquisition', report: 'acquisition', label: 'Acquisition' },\n\t{ id: 'typeface-interest', report: 'typeface-interest', label: 'Typeface interest' },\n\t{ id: 'journey', report: 'journey', label: 'Journey' },\n]\n\n/** Options supplied by the plugin config, carried on the Sanity tool definition. */\nexport interface VisitorInsightsToolProps {\n\tapiBaseUrl: string\n\tsiteLabel: string\n}\n\n/**\n * What Sanity actually hands a tool component.\n *\n * Sanity does NOT spread a tool's `options` onto its component's props — it passes the whole tool\n * definition as `tool`, with the options nested inside. Destructuring `apiBaseUrl` straight off\n * props therefore yields undefined, and the first thing useReport does with it is call .replace(),\n * so every panel failed with \"Cannot read properties of undefined (reading 'replace')\".\n *\n * Both shapes are accepted: the nested one because that is what the Studio passes, and the flat one\n * because the component is exported for direct use and is mounted that way in tests.\n */\nexport interface VisitorInsightsToolComponentProps extends Partial<VisitorInsightsToolProps> {\n\ttool?: { options?: Partial<VisitorInsightsToolProps> }\n}\n\n/**\n * Range selector.\n * Arrow keys move between options and select as they go, which is the expected behaviour for a\n * radiogroup; Tab enters and leaves the group as a single stop.\n */\nfunction RangeSelector({\n\tvalue,\n\tcustom,\n\tonChange,\n\tonCustomChange,\n}: {\n\tvalue: RangeKey\n\tcustom: { start: string; end: string }\n\tonChange: (next: RangeKey) => void\n\tonCustomChange: (next: { start: string; end: string }) => void\n}): React.ReactElement {\n\tconst refs = useRef<Array<HTMLButtonElement | null>>([])\n\tconst [pickerOpen, setPickerOpen] = useState(value === 'custom')\n\t// The picker edits a DRAFT and lifts it only on Apply.\n\t//\n\t// The comment below promised apply-on-submit and delivered it only on first entry into custom\n\t// mode, because Apply was what set the range. After a brush the range is ALREADY custom, so\n\t// every keystroke in a date field updated the live range and fanned out to four upstreams —\n\t// spinning the year field is several full fetches against a ten-concurrent ceiling.\n\tconst [draft, setDraft] = useState(custom)\n\t// Re-seeded whenever the committed range changes from outside, e.g. by a brush, so opening the\n\t// picker shows the window the reader is actually looking at.\n\tReact.useEffect(() => { setDraft(custom) }, [custom.start, custom.end])\n\n\tconst onKeyDown = useCallback(\n\t\t(event: React.KeyboardEvent) => {\n\t\t\tconst currentIndex = RANGES.findIndex((r) => r.key === value)\n\t\t\tlet nextIndex: number | null = null\n\n\t\t\tif (event.key === 'ArrowRight' || event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % RANGES.length\n\t\t\tif (event.key === 'ArrowLeft' || event.key === 'ArrowUp') nextIndex = (currentIndex - 1 + RANGES.length) % RANGES.length\n\t\t\tif (event.key === 'Home') nextIndex = 0\n\t\t\tif (event.key === 'End') nextIndex = RANGES.length - 1\n\n\t\t\tif (nextIndex === null) return\n\n\t\t\tevent.preventDefault()\n\t\t\tconst next = RANGES[nextIndex]\n\t\t\tif (!next) return\n\n\t\t\tsetPickerOpen(false)\n\t\t\tonChange(next.key)\n\t\t\trefs.current[nextIndex]?.focus()\n\t\t},\n\t\t[value, onChange],\n\t)\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t<div style={controlRow}>\n\t\t\t<div role=\"radiogroup\" aria-label=\"Date range\" style={controlRow} onKeyDown={onKeyDown}>\n\t\t\t\t{RANGES.map((range, index) => {\n\t\t\t\t\tconst selected = range.key === value\n\t\t\t\t\treturn (\n\t\t\t\t\t\t// Plain buttons rather than the UI kit's: the compat shim's DOM fallback does not\n\t\t\t\t\t\t// forward `text`, which renders the whole selector as blank boxes on any Studio\n\t\t\t\t\t\t// version where it cannot resolve Button.\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={range.key}\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tref={(el: HTMLButtonElement | null) => {\n\t\t\t\t\t\t\t\trefs.current[index] = el\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\trole=\"radio\"\n\t\t\t\t\t\t\taria-checked={selected}\n\t\t\t\t\t\t\t// Only the selected option is in the tab order, so the group is one stop.\n\t\t\t\t\t\t\t// Falls back to the first option when nothing is selected. A roving tabindex\n\t\t\t\t\t\t\t// with no fallback dropped the whole group out of the tab order the moment\n\t\t\t\t\t\t\t// the range became `custom` — which is exactly what brushing sets, so the\n\t\t\t\t\t\t\t// reward for using the keyboard-reachable brush was a mouse-only range\n\t\t\t\t\t\t\t// control.\n\t\t\t\t\t\t\ttabIndex={selected || (!RANGES.some((r) => r.key === value) && index === 0) ? 0 : -1}\n\t\t\t\t\t\t\ttitle={`Ending today, covering ${range.span}`}\n\t\t\t\t\t\t\tstyle={rangeButton(selected)}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetPickerOpen(false)\n\t\t\t\t\t\t\t\tonChange(range.key)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{range.label}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)\n\t\t\t\t})}\n\n\t\t\t</div>\n\n\t\t\t{/* OUTSIDE the radiogroup element, not merely outside its arrow cycle. It was a tabbable\n\t\t\t non-radio child of role=\"radiogroup\" — an ARIA violation, and a second tab stop inside\n\t\t\t a group documented as being one. */}\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tstyle={rangeButton(value === 'custom')}\n\t\t\t\t\taria-expanded={pickerOpen}\n\t\t\t\t\tonClick={() => setPickerOpen((open) => !open)}\n\t\t\t\t>\n\t\t\t\t\t{value === 'custom' ? `${custom.start} to ${custom.end}` : 'Custom…'}\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t{pickerOpen && (\n\t\t\t\t<div style={pickerRow}>\n\t\t\t\t\t<label style={pickerField}>\n\t\t\t\t\t\t<Text size={0} muted>From</Text>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"date\"\n\t\t\t\t\t\t\tvalue={draft.start}\n\t\t\t\t\t\t\tmax={draft.end || undefined}\n\t\t\t\t\t\t\tstyle={dateInput}\n\t\t\t\t\t\t\tonChange={(e) => setDraft((d) => ({ ...d, start: e.currentTarget.value }))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</label>\n\t\t\t\t\t<label style={pickerField}>\n\t\t\t\t\t\t<Text size={0} muted>To</Text>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"date\"\n\t\t\t\t\t\t\tvalue={draft.end}\n\t\t\t\t\t\t\tmin={draft.start || undefined}\n\t\t\t\t\t\t\tstyle={dateInput}\n\t\t\t\t\t\t\tonChange={(e) => setDraft((d) => ({ ...d, end: e.currentTarget.value }))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</label>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tstyle={rangeButton(true)}\n\t\t\t\t\t\t// Applied on submit, not on each keystroke: a half-typed year is a valid-looking\n\t\t\t\t\t\t// date, and refetching per character would fire a run of requests nobody asked for.\n\t\t\t\t\t\tdisabled={!draft.start || !draft.end}\n\t\t\t\t\t\tonClick={() => { onCustomChange(draft); onChange('custom') }}\n\t\t\t\t\t>\n\t\t\t\t\t\tApply\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/** Human labels for the upstreams, so the row does not read as internal jargon. */\nconst SOURCE_LABEL: Record<SourceName, string> = {\n\tga4: 'Google Analytics',\n\tvercel: 'Vercel',\n\tsanity: 'Orders',\n\tmailchimp: 'Mailchimp',\n}\n\n/**\n * Which sources answered.\n *\n * The envelope has carried this from the start and nothing displayed it, so a panel built on two\n * of three sources looked identical to one built on all three. Partial failure is the normal case\n * here — an unconfigured Vercel project, an expired service account — and the difference between\n * \"this number is low\" and \"we could not ask\" has to be visible.\n *\n * Renders nothing when everything answered, so the healthy case stays quiet.\n */\nfunction SourceStatusRow({ sources }: { sources: Partial<Record<SourceName, SourceStatus>> }): React.ReactElement | null {\n\tconst degraded = (Object.entries(sources) as Array<[SourceName, SourceStatus]>).filter(([, s]) => s.status !== 'ok')\n\tif (degraded.length === 0) return null\n\n\treturn (\n\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t<Flex gap={3} align=\"center\" wrap=\"wrap\">\n\t\t\t\t<Text size={1} weight=\"semibold\">Incomplete data:</Text>\n\t\t\t\t{degraded.map(([name, status]) => (\n\t\t\t\t\t<Flex key={name} gap={2} align=\"center\">\n\t\t\t\t\t\t<Badge tone={status.status === 'error' ? 'critical' : 'default'} fontSize={0}>\n\t\t\t\t\t\t\t{SOURCE_LABEL[name]}\n\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\t\t{status.status === 'error' ? status.message : 'not configured for this site'}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Flex>\n\t\t\t\t))}\n\t\t\t</Flex>\n\t\t</Card>\n\t)\n}\n\n/**\n * Panel tabs.\n *\n * Hand-rolled rather than taken from @sanity/ui so the keyboard contract is explicit and identical\n * across the Studio versions this package supports: arrow keys move and select, Tab is a single\n * stop into the group, and each tab is wired to its panel by id.\n */\nfunction PanelTabs({ value, onChange }: { value: string; onChange: (next: string) => void }): React.ReactElement {\n\tconst refs = useRef<Array<HTMLButtonElement | null>>([])\n\n\tconst onKeyDown = useCallback(\n\t\t(event: React.KeyboardEvent) => {\n\t\t\tconst currentIndex = PANELS.findIndex((p) => p.id === value)\n\t\t\tlet nextIndex: number | null = null\n\n\t\t\tif (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % PANELS.length\n\t\t\tif (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + PANELS.length) % PANELS.length\n\t\t\tif (event.key === 'Home') nextIndex = 0\n\t\t\tif (event.key === 'End') nextIndex = PANELS.length - 1\n\n\t\t\tif (nextIndex === null) return\n\n\t\t\tevent.preventDefault()\n\t\t\tconst next = PANELS[nextIndex]\n\t\t\tif (!next) return\n\n\t\t\tonChange(next.id)\n\t\t\trefs.current[nextIndex]?.focus()\n\t\t},\n\t\t[value, onChange],\n\t)\n\n\treturn (\n\t\t<div role=\"tablist\" aria-label=\"Report\" style={tabStrip} onKeyDown={onKeyDown}>\n\t\t\t{PANELS.map((panel, index) => {\n\t\t\t\tconst selected = panel.id === value\n\t\t\t\treturn (\n\t\t\t\t\t// A plain button rather than the UI kit's, so the label is real children and\n\t\t\t\t\t// cannot vanish: the compat shim's DOM fallback does not forward `text`, which\n\t\t\t\t\t// would leave five blank tabs on any Studio version where it falls back.\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={panel.id}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tref={(el: HTMLButtonElement | null) => {\n\t\t\t\t\t\t\trefs.current[index] = el\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tid={`tab-${panel.id}`}\n\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\taria-selected={selected}\n\t\t\t\t\t\taria-controls={`panel-${panel.id}`}\n\t\t\t\t\t\ttabIndex={selected ? 0 : -1}\n\t\t\t\t\t\tstyle={tabStyle(selected)}\n\t\t\t\t\t\tonClick={() => onChange(panel.id)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{panel.label}\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n}\n\n/**\n * How much of reality the figures below are drawn from, on the tabs that cannot say it themselves.\n *\n * The tool used to declare \"treat its figures as broken\" on Overview and then hand the reader three\n * screens of the same instrument's output with no marker at all. This is not a caveat about the\n * tool; it is a multiplier on every number under it, which is why it sits above them and states the\n * figure rather than a mood.\n *\n * Renders nothing when the shortfall is unknown or small. Unknown has to be silent: a panel that\n * cannot say how lossy its source is must not imply the source is fine.\n *\n * @param ratio - the fraction of reality GA4 is MISSING, 0 to 1\n */\nfunction CoverageRibbon({ ratio }: { ratio: number | null }): React.ReactElement | null {\n\t/*\n\t * Unknown is said, not left blank.\n\t *\n\t * The shortfall is learned only when a measurement-health envelope resolves for this exact\n\t * window — which happens on Overview and Data health. Arrive on Acquisition by link, or change\n\t * the range while sitting here, and it is simply not known: the ribbon rendered nothing, which\n\t * on a tab built entirely from GA4 is indistinguishable from \"coverage is fine\". A tool whose\n\t * subject is the difference between \"measured nothing\" and \"did not measure\" cannot make that\n\t * mistake about itself.\n\t */\n\tif (ratio === null) {\n\t\treturn (\n\t\t\t<Text size={0} muted>\n\t\t\t\tFigures marked (GA4) on this tab come from Google Analytics. How much of your traffic it is\n\t\t\t\tseeing has not been checked for this window — open Overview to measure it.\n\t\t\t</Text>\n\t\t)\n\t}\n\n\t// Below a fifth the pageview comparison is not distinguishable from crawlers and prefetches\n\t// Vercel counts and GA4 never sees, so a ribbon there would cry wolf on every load.\n\tif (ratio < 0.2) return null\n\n\tconst seen = Math.round((1 - ratio) * 100)\n\t// Coarse on purpose. \"5.0×\" is a point estimate with a decimal place, printed larger and louder\n\t// than anything else in the tool, on a quantity every other surface here wraps in an interval\n\t// and an Estimated badge. The multiplier is the actionable direction — that was the reason for\n\t// stating it — but it does not get to claim a precision the figure behind it has not earned.\n\tconst multiplier = seen > 0 ? Math.round(100 / seen) : null\n\treturn (\n\t\t<Card padding={3} radius={2} tone={ratio > 0.6 ? 'critical' : 'caution'} border>\n\t\t\t{/* Sixty-two words, on three tabs, on every load. Cut to the two claims a reader has to\n\t\t\t act on: how low these figures run, and which ones not to touch.\n\t\t\t\n\t\t\t \"anything labelled as coming from Sanity\" went because it names a system the reader\n\t\t\t has no reason to recognise — they know it as where they type. The column headers\n\t\t\t already carry (GA4) and (orders), which is the same distinction in the place it\n\t\t\t applies. \"Data health explains the rest\" went because the tab is named in the strip\n\t\t\t directly above this card. */}\n\t\t\t<Text size={1}>\n\t\t\t\tGoogle Analytics is seeing about {seen}% of your traffic here, so treat the figures below\n\t\t\t\tas roughly {multiplier === null ? 'many times' : `${multiplier}×`} low. Anything marked\n\t\t\t\t(orders) is exact — do not scale it.\n\t\t\t</Text>\n\t\t</Card>\n\t)\n}\n\n/**\n * The ready state of a report panel: everything drawn once an envelope has arrived.\n *\n * Extracted as a PURE component, taking the resolved envelope rather than the fetch state, so it can\n * be rendered in a test. Nothing in the suite mounted `ReportPanel` — it only reaches this markup\n * after a fetch resolves, which needs effects and a DOM the tests do not have — and in that blind\n * spot two structural bugs shipped, the second of which left this whole block nested inside a\n * caption and blanked three of the five tabs. Type-checking cannot see JSX nesting; a render can.\n */\nexport function ReadyReport({\n\tenvelope,\n\ttabId,\n\tapiBaseUrl,\n\trange,\n\tcustom,\n\trevalidationError,\n\tdiagnostics,\n\tonBrush,\n}: {\n\tenvelope: ReportEnvelope<unknown>\n\ttabId: string\n\tapiBaseUrl: string\n\trange: RangeKey\n\tcustom: { start: string; end: string }\n\t/** Message from a failed background refresh, or null. */\n\trevalidationError: string | null\n\t/** The configuration checks, shown only on Data health. */\n\tdiagnostics: ReportState<unknown>\n\tonBrush?: (start: string, end: string) => void\n}): React.ReactElement {\n\treturn (\n\t\t\t\t<Stack space={4}>\n\t\t\t\t\t{/*\n\t\t\t\t\t * Above the figures, because each of these three changes how the figures are\n\t\t\t\t\t * READ. That is the axis, not instrument-versus-business — the sort used when\n\t\t\t\t\t * this last moved was wrong on exactly the cases where the blocks exist. The\n\t\t\t\t\t * source row renders only when a source failed, so when it is here at all it\n\t\t\t\t\t * explains why a panel below is empty; the revalidation card says the figures\n\t\t\t\t\t * are stale before they are read rather than after; and the comparison sentence\n\t\t\t\t\t * defines every delta underneath it, which makes it a legend, not a footnote.\n\t\t\t\t\t * The standing caveats stay below the answer: those do not change day to day,\n\t\t\t\t\t * and they were the boilerplate that used to be the peak of the page.\n\t\t\t\t\t */}\n\t\t\t\t\t<SourceStatusRow sources={envelope.sources} />\n\t\t\t\t\t{revalidationError && (\n\t\t\t\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\tThese figures are the last ones that loaded. Refreshing them failed: {revalidationError}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t)}\n\t\t\t\t\t{/* Shown only on the panels that actually draw a delta, and gated on the TAB rather\n\t\t\t\t\t than the report — Data health's report is measurement-health, so gating on the\n\t\t\t\t\t report printed \"Changes are against…\" above a panel that draws not one delta.\n\n\t\t\t\t\t This <Text> is CLOSED here. It was not: an earlier reorder left it open, and\n\t\t\t\t\t every panel body, the coverage ribbon and the notices became children of it —\n\t\t\t\t\t so the three tabs outside COMPARED_TABS rendered nothing but the footer, and\n\t\t\t\t\t the two that did render drew the whole report inside a size-0 muted caption.\n\t\t\t\t\t It compiled, it type-checked, and no test mounted this component. */}\n\t\t\t\t\t{envelope.comparison && COMPARED_TABS.includes(tabId) && (\n\t\t\t\t\t\t<Text size={0} style={{ color: COMPARISON_TEXT }}>\n\t\t\t\t\t\t\tChanges are against {envelope.comparison.range.start} to {envelope.comparison.range.end},\n\t\t\t\t\t\t\t{/* Named, not implied. Once the baseline can be chosen, a sentence that says\n\t\t\t\t\t\t\t only \"the equivalent window\" leaves the reader to work out which one is in\n\t\t\t\t\t\t\t force from the dates — and a year-ago comparison misread as last month's\n\t\t\t\t\t\t\t is a wrong conclusion, not a cosmetic one. */}\n\t\t\t\t\t\t\t{envelope.comparison.basis === 'same-period-last-year'\n\t\t\t\t\t\t\t\t? ' the same window a year earlier.'\n\t\t\t\t\t\t\t\t: ' the equivalent window immediately before this one.'}\n\t\t\t\t\t\t\t{envelope.comparison.provisional && (\n\t\t\t\t\t\t\t\t<> This window’s last days are still being processed by GA4, so changes read low.</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{GA4_ONLY_TABS.includes(tabId) && (\n\t\t\t\t\t\t<CoverageRibbon ratio={knownShortfall(apiBaseUrl, range, range === 'custom' ? custom : undefined)} />\n\t\t\t\t\t)}\n\n\t\t\t\t\t{tabId === 'overview' && (\n\t\t\t\t\t\t<div style={panelStack}>\n\t\t\t\t\t\t\t<OverviewPanel data={envelope.data as never} previous={envelope.comparison?.data as never} onBrush={onBrush} />\n\t\t\t\t\t\t\t{/* The instrument, on the same page as the figures it qualifies, folded.\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t The containment bar states one total and the timeline's coverage row states\n\t\t\t\t\t\t\t the same quantity daily; dating a collapse means reading the shape and the\n\t\t\t\t\t\t\t total together, and a tab forbade exactly that. Folded because it is\n\t\t\t\t\t\t\t consulted rather than read — and safe to fold because Section keeps its\n\t\t\t\t\t\t\t subtitle outside the fold, so the reading itself stays on screen. */}\n\t\t\t\t\t\t\t<Section\n\t\t\t\t\t\t\t\ttitle=\"Can I trust these figures\"\n\t\t\t\t\t\t\t\ttone=\"secondary\"\n\t\t\t\t\t\t\t\tsubtitle={trustSubtitle(envelope.data as never)}\n\t\t\t\t\t\t\t\tcollapsible\n\t\t\t\t\t\t\t\tdefaultOpen={false}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<DataHealthPanel\n\t\t\t\t\t\t\t\t\tdata={envelope.data as never}\n\t\t\t\t\t\t\t\t\tdiagnostics={diagnostics.status === 'ready' ? (diagnostics.envelope.data as never) : undefined}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t{/* Said, not silently absent. The configuration block rendered only on\n\t\t\t\t\t\t\t\t `ready`, so an in-flight fetch or a 500 showed nothing at all and the\n\t\t\t\t\t\t\t\t section popped in later with no explanation of where it had been. */}\n\t\t\t\t\t\t\t\t{diagnostics.status === 'loading' && <Text size={1} muted>Checking configuration…</Text>}\n\t\t\t\t\t\t\t\t{diagnostics.status === 'error' && (\n\t\t\t\t\t\t\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t\t\t\t\t\t\t<Text size={1}>Configuration checks could not run: {diagnostics.message}</Text>\n\t\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</Section>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{tabId === 'acquisition' && <AcquisitionPanel data={envelope.data as never} previous={envelope.comparison?.data as never} />}\n\t\t\t\t\t{tabId === 'journey' && <JourneyPanel data={envelope.data as never} />}\n\t\t\t\t\t{tabId === 'typeface-interest' && <TypefaceInterestPanel data={envelope.data as never} />}\n\n\t\t\t\t\t{/* The standing caveats, below the answer. These read the same most days — a\n\t\t\t\t\t source that is configured but lossy, an event with no cutover date — so at the\n\t\t\t\t\t top they were amber wallpaper above every figure in the tool. */}\n\t\t\t\t\t<NoticeList notices={envelope.notices} />\n\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\tFigures cover {envelope.range.start} to {envelope.range.end}, in {envelope.range.timezone}\n\t\t\t\t\t</Text>\n\t\t\t\t</Stack>\n\t)\n}\n\n/**\n * Which baseline the deltas are drawn against.\n *\n * Type sales are seasonal — a foundry's quiet August against a quiet July says far less than\n * August against last August — and the preceding window was the only baseline available. Both cost\n * the same: the server fetches one comparison window either way, just a different one.\n */\nfunction BasisSelector({\n\tvalue,\n\tonChange,\n}: {\n\tvalue: 'previous-period' | 'same-period-last-year'\n\tonChange: (value: 'previous-period' | 'same-period-last-year') => void\n}): React.ReactElement {\n\t// `group`, not `radiogroup`. A radiogroup has to own role=\"radio\" children with aria-checked and\n\t// roving arrow-key focus; these are toggle buttons with a text label beside them, and claiming\n\t// the stronger role told a screen reader to expect navigation that does not exist.\n\treturn (\n\t\t<div style={controlRow} role=\"group\" aria-label=\"Comparison basis\">\n\t\t\t{/* Not muted: this label is the head of the comparison identity, and every delta on the\n\t\t\t panel is drawn in the same colour. */}\n\t\t\t<Text size={0} style={{ color: COMPARISON_TEXT }}>Compared with</Text>\n\t\t\t{/* Plain buttons for the same reason the range selector uses them: the compat shim's DOM\n\t\t\t fallback does not forward `text`, so the UI kit's Button renders as a blank box. */}\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tstyle={basisButton(value === 'previous-period')}\n\t\t\t\taria-pressed={value === 'previous-period'}\n\t\t\t\ttitle=\"The equivalent window immediately before this one\"\n\t\t\t\tonClick={() => onChange('previous-period')}\n\t\t\t>\n\t\t\t\tPrevious period\n\t\t\t</button>\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tstyle={basisButton(value === 'same-period-last-year')}\n\t\t\t\taria-pressed={value === 'same-period-last-year'}\n\t\t\t\ttitle=\"The same window a year earlier, shifted by 364 days so the weekdays line up\"\n\t\t\t\tonClick={() => onChange('same-period-last-year')}\n\t\t\t>\n\t\t\t\tLast year\n\t\t\t</button>\n\t\t</div>\n\t)\n}\n\n/**\n * The one-line answer that stands in for the trust band while it is folded.\n *\n * `Section` renders a subtitle outside the fold, which is the mechanism that makes folding legal in\n * a package whose founding rule is that a caveat sits beside its figure. So the coverage reading —\n * the single fact a reader wants before trusting anything above — is what goes here, rather than a\n * description of what is inside.\n *\n * @param data - the measurement-health envelope\n */\nfunction trustSubtitle(data: { shortfallRatio?: number | null } | undefined): string {\n\tconst shortfall = data?.shortfallRatio\n\tif (shortfall === null || shortfall === undefined || !Number.isFinite(shortfall)) {\n\t\treturn 'How much of your traffic Google Analytics is seeing, and what it is missing.'\n\t}\n\tconst seeing = Math.round((1 - shortfall) * 100)\n\treturn shortfall >= 0\n\t\t? `Google Analytics is seeing about ${seeing}% of your traffic. Open for how that was measured.`\n\t\t: 'Google Analytics is counting more than your server does. Open for how that was measured.'\n}\n\n/** Renders one report panel, including its loading, error and empty states. */\nfunction ReportPanel({\n\ttabId,\n\treport,\n\tapiBaseUrl,\n\trange,\n\tcustom,\n\tcompare,\n\tonBrush,\n}: {\n\t/** The tab being drawn. No longer the same as `report`: two tabs share one envelope. */\n\ttabId: string\n\treport: ReportName\n\tapiBaseUrl: string\n\trange: RangeKey\n\tcustom: { start: string; end: string }\n\tcompare?: 'previous-period' | 'same-period-last-year'\n\tonBrush?: (start: string, end: string) => void\n}): React.ReactElement {\n\tconst { state, reload } = useReport<unknown>({ apiBaseUrl, report, range, custom, compare })\n\t// Data health additionally shows the configuration checks. Fetched only on that tab, so the\n\t// four tabs that do not show them do not pay for them.\n\t// A fixed range: runDiagnostics takes none — it probes the present configuration — so keying it\n\t// on the selected range fired an identical set of live GA4, Vercel and Sanity probes on every\n\t// Week→Month→Quarter switch and cached three copies of one answer.\n\tconst diagnostics = useReport<unknown>({\n\t\tapiBaseUrl,\n\t\treport: 'diagnostics',\n\t\trange: 'week',\n\t\tenabled: tabId === 'overview',\n\t}).state\n\n\t// Announced to screen readers when figures change, so a range switch is perceivable without\n\t// re-navigating the whole panel.\n\tconst liveMessage =\n\t\tstate.status === 'loading'\n\t\t\t? 'Loading report'\n\t\t\t: state.status === 'ready'\n\t\t\t\t? `${PANELS.find((p) => p.id === tabId)?.label ?? tabId} updated for ${range === 'custom' ? `${custom.start} to ${custom.end}` : `the selected ${range}`}`\n\t\t\t\t: state.status === 'error'\n\t\t\t\t\t? state.disabled\n\t\t\t\t\t\t? 'Visitor insights is switched off for this site'\n\t\t\t\t\t\t: `Report failed: ${state.message}`\n\t\t\t\t\t: ''\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Box aria-live=\"polite\" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }}>\n\t\t\t\t{liveMessage}\n\t\t\t</Box>\n\n\t\t\t{state.status === 'ready' && state.stale && (\n\t\t\t\t// A quiet marker, and nothing else. The panel used to render at 0.55 opacity while\n\t\t\t\t// stale — but every cache hit and every range change sets that flag, so the dominant\n\t\t\t\t// felt state of the tool was \"slightly unreadable\", and muted text at 55% is under\n\t\t\t\t// 3:1 in both themes. The line says it; the figures stay legible.\n\t\t\t\t<Text size={0} muted>Updating…</Text>\n\t\t\t)}\n\n\t\t\t{state.status === 'loading' && (\n\t\t\t\t<Flex align=\"center\" gap={3} padding={4}>\n\t\t\t\t\t<Spinner muted />\n\t\t\t\t\t<Text size={1} muted>Loading…</Text>\n\t\t\t\t</Flex>\n\t\t\t)}\n\n\t\t\t{state.status === 'error' && (\n\t\t\t\t// A switched-off site is a configuration state, not a fault: it gets a neutral card\n\t\t\t\t// and no retry, because retrying cannot change the answer. Everything else keeps\n\t\t\t\t// the critical tone and the retry.\n\t\t\t\t<Card padding={4} radius={2} tone={state.disabled ? 'transparent' : 'critical'} border>\n\t\t\t\t\t<Stack space={3}>\n\t\t\t\t\t\t{state.disabled && <Text size={1} weight=\"medium\">Switched off</Text>}\n\t\t\t\t\t\t<Text size={1} muted={state.disabled}>{state.message}</Text>\n\t\t\t\t\t\t{!state.disabled && (\n\t\t\t\t\t\t\t<Box>\n\t\t\t\t\t\t\t\t{/* A plain button. The shim's DOM fallback drops `text`, `mode` and `fontSize`, so\n\t\t\t\t\t\t\t\t this rendered as an unlabelled zero-size control — on the one screen\n\t\t\t\t\t\t\t\t where the reader most needs a working one. Every other control in this\n\t\t\t\t\t\t\t\t file already avoids Button for that reason; the error state did not\n\t\t\t\t\t\t\t\t get the lesson. */}\n\t\t\t\t\t\t\t\t<button type=\"button\" style={rangeButton(false)} onClick={reload}>Try again</button>\n\t\t\t\t\t\t\t</Box>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Stack>\n\t\t\t\t</Card>\n\t\t\t)}\n\n\t\t\t{state.status === 'ready' && (\n\t\t\t\t<ReadyReport\n\t\t\t\t\tenvelope={state.envelope}\n\t\t\t\t\ttabId={tabId}\n\t\t\t\t\tapiBaseUrl={apiBaseUrl}\n\t\t\t\t\trange={range}\n\t\t\t\t\tcustom={custom}\n\t\t\t\t\trevalidationError={state.revalidationError ?? null}\n\t\t\t\t\tdiagnostics={diagnostics}\n\t\t\t\t\tonBrush={onBrush}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/**\n * An ISO date N days before today, in the viewer's local zone.\n *\n * @param days - how many days back; 0 is today\n */\nfunction isoDaysAgo(days: number): string {\n\t// Stepped in UTC throughout. It previously shifted the date in LOCAL time and then serialised\n\t// with toISOString(), which is UTC — so anyone east of UTC got a default range one day short.\n\tconst now = new Date()\n\tconst utcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())\n\treturn new Date(utcMidnight - days * 86_400_000).toISOString().slice(0, 10)\n}\n\n/**\n * Reports that draw a period-over-period delta. Must match the server's own list, which decides\n * which reports pay for a second window.\n */\nconst COMPARED_REPORTS: ReportName[] = ['acquisition', 'measurement-health']\n\n/** Tabs that actually draw a delta. Not the same as the reports that fetch a comparison window. */\nconst COMPARED_TABS = ['overview', 'acquisition']\n\n/**\n * Catches a render error in one panel so it does not blank the whole tool.\n *\n * There was no boundary anywhere. An uncaught throw unmounts the entire React tree, so a single bad\n * field reference took out the tabs and the range selector along with the panel — which is what\n * happened twice, and both times the reader lost four working panels to fix one.\n */\nclass PanelBoundary extends React.Component<\n\t{ children: React.ReactNode; resetKey: string },\n\t{ error: Error | null; shownFor: string }\n> {\n\tconstructor(props: { children: React.ReactNode; resetKey: string }) {\n\t\tsuper(props)\n\t\tthis.state = { error: null, shownFor: props.resetKey }\n\t}\n\n\tstatic getDerivedStateFromError(error: Error) {\n\t\treturn { error }\n\t}\n\n\t/**\n\t * Clear a caught error when the tab changes, WITHOUT remounting the subtree.\n\t *\n\t * This boundary used to be keyed on the active tab, which cleared the error by throwing the\n\t * whole subtree away — and with it every table's sort, filter and exclusions, and `useReport`'s\n\t * state, which resets to `idle` and refetches. So the natural exploratory loop — sort\n\t * Acquisition by engagement, check Journey, come back — always cost the reader their work, on a\n\t * component that invests real care in preserving exactly that state across a RANGE change.\n\t *\n\t * A reset key does the one thing the mount key was there for and nothing else.\n\t */\n\tstatic getDerivedStateFromProps(\n\t\tprops: { resetKey: string },\n\t\tstate: { error: Error | null; shownFor: string },\n\t) {\n\t\tif (props.resetKey !== state.shownFor) return { error: null, shownFor: props.resetKey }\n\t\treturn null\n\t}\n\n\tcomponentDidCatch(error: Error) {\n\t\tconsole.error('Visitor insights: panel render failed:', error.message)\n\t}\n\n\trender() {\n\t\tif (!this.state.error) return this.props.children\n\t\treturn (\n\t\t\t<Card padding={4} radius={2} tone=\"critical\" border>\n\t\t\t\t<Stack space={3}>\n\t\t\t\t\t<Text size={1} weight=\"medium\">This panel could not be drawn</Text>\n\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\tThe other panels are unaffected. This usually means the site’s API route is a\n\t\t\t\t\t\tdifferent version from this Studio — redeploying the site normally clears it.\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text size={0} muted>{this.state.error.message}</Text>\n\t\t\t\t\t{/* No retry button. The crash class this catches is a version-skew field access,\n\t\t\t\t\t which is deterministic: clearing the error re-renders the identical subtree\n\t\t\t\t\t against the identical cached envelope and throws again immediately. The\n\t\t\t\t\t previous button called setActivePanel with the value it already had, so React\n\t\t\t\t\t bailed out and nothing refetched — it did nothing twice over. Switching tabs\n\t\t\t\t\t resets the boundary, which is the honest escape. */}\n\t\t\t\t\t<Text size={0} muted>Switch tabs and back, or redeploy the site, to clear this.</Text>\n\t\t\t\t</Stack>\n\t\t\t</Card>\n\t\t)\n\t}\n}\n\n/** The tool itself. */\nexport { PanelBoundary }\n\nexport function VisitorInsightsTool(props: VisitorInsightsToolComponentProps): React.ReactElement {\n\t// Nested options win, since that is the shape the Studio supplies; the flat props are the\n\t// direct-use fallback. See VisitorInsightsToolComponentProps for why both exist.\n\tconst apiBaseUrl = props.tool?.options?.apiBaseUrl ?? props.apiBaseUrl ?? ''\n\t// Kept for the document title and accessible naming, no longer printed as a subtitle.\n\tconst siteLabel = props.tool?.options?.siteLabel ?? props.siteLabel ?? ''\n\n\t/*\n\t * The view a link asked for, read once at mount.\n\t *\n\t * Read lazily inside useState so it happens on the first render rather than after it — seeding\n\t * from a default and then correcting in an effect would fire a fetch for the week, throw it\n\t * away, and fetch again for the window the link actually named.\n\t */\n\tconst [linked] = useState<ViewState>(() => (typeof window === 'undefined' ? {} : decodeView(window.location.hash)))\n\t// Validated against the tabs and ranges that exist. A link naming something this build does not\n\t// have falls back rather than selecting nothing.\n\tconst linkedRange = RANGES.some((r) => r.key === linked.range) ? (linked.range as RangeKey) : null\n\tconst linkedTab = PANELS.some((p) => p.id === linked.tab) ? (linked.tab as string) : null\n\n\t/*\n\t * Month, not week.\n\t *\n\t * A week is seven days, and the coverage-incident detector needs twenty-one measured days before\n\t * it will name a date — so on the range the tool opened with, the founding failure it was built\n\t * to surface could not be reported at all. At seven orders a quarter a week also holds nought or\n\t * one order, so the headline read \"Revenue £0\" and the cards showed changes of 100% or \"new\".\n\t * The default view was the one view with nothing to say.\n\t */\n\tconst [range, setRange] = useState<RangeKey>(linkedRange ?? 'month')\n\t// The range in force before a brush, so the escape hatch returns where the reader was rather\n\t// than to a week they never chose. Brushing from Quarter and landing in Week loses 358 days,\n\t// and the label said so — a stated wrong answer rather than a bug you could rationalise.\n\tconst [rangeBeforeBrush, setRangeBeforeBrush] = useState<RangeKey>(linkedRange ?? 'month')\n\t// Seeded with the trailing month so the picker opens on a valid range rather than on two empty\n\t// fields. Local dates, not the property's: this is only the form's starting value, and the\n\t// server re-resolves whatever is submitted against the property timezone.\n\tconst [custom, setCustom] = useState(() => (linked.from && linked.to\n\t\t? { start: linked.from, end: linked.to }\n\t\t: { start: isoDaysAgo(30), end: isoDaysAgo(0) }))\n\t/** Whether the custom window already runs to today, so forward panning has nowhere to go. */\n\tconst atPresent = custom.end >= isoDaysAgo(0)\n\t/**\n\t * What the deltas are measured against.\n\t *\n\t * Type sales are seasonal, so \"the previous thirty days\" is often the least informative baseline\n\t * available — and it was the only one. Both cost the same: one comparison window either way.\n\t */\n\tconst [compare, setCompare] = useState<'previous-period' | 'same-period-last-year'>('previous-period')\n\n\tconst [activePanel, setActivePanel] = useState<string>(linkedTab ?? 'overview')\n\n\t// And write it back, so the address bar always describes what is on screen.\n\t//\n\t// replaceState, not pushState: Sanity owns the history stack for its own navigation, and adding\n\t// an entry per range click would make the Studio's back button walk through this tool's\n\t// filters instead of leaving the tool. The link is still copyable at any moment, which is the\n\t// thing that was missing.\n\tReact.useEffect(() => {\n\t\tif (typeof window === 'undefined') return\n\t\tconst next = mergeIntoHash(window.location.hash, {\n\t\t\ttab: activePanel,\n\t\t\trange,\n\t\t\tfrom: custom.start,\n\t\t\tto: custom.end,\n\t\t})\n\t\tif (next !== window.location.hash) {\n\t\t\twindow.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${next}`)\n\t\t}\n\t}, [activePanel, range, custom.start, custom.end])\n\n\tconst active = PANELS.find((p) => p.id === activePanel) ?? PANELS[0]\n\n\treturn (\n\t\t// Named for assistive technology even though the name is not printed: a Studio user with\n\t\t// several tabs open hears which site's figures these are, without the heading repeating\n\t\t// what the Studio navigation already says.\n\t\t<Container width={4} padding={4} as=\"section\" aria-label={siteLabel ? `Visitor insights for ${siteLabel}` : 'Visitor insights'}>\n\t\t\t{/* Defines --vi-comparison once for the whole tool. It carries a per-theme pair where the\n\t\t\t browser supports light-dark(), and every use site carries the balanced colour as a\n\t\t\t var() fallback, so this element failing to render degrades rather than breaks. */}\n\t\t\t<style>{COMPARISON_STYLE}</style>\n\t\t\t<Stack space={5}>\n\t\t\t\t<Flex align=\"flex-start\" justify=\"space-between\" gap={4} wrap=\"wrap\">\n\t\t\t\t\t{/* No site name. The tool is mounted inside that site's own Studio, which already\n\t\t\t\t\t says whose it is in the navigation — repeating it was a line of chrome\n\t\t\t\t\t where the range and its dates are the useful context. */}\n\t\t\t\t\t<Heading size={2}>Visitor insights</Heading>\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<RangeSelector value={range} custom={custom} onChange={setRange} onCustomChange={setCustom} />\n\t\t\t\t\t\t{/* Only where a delta is actually drawn.\n\t\t\t\t\t\t\n\t\t\t\t\t\t It rendered on all five tabs and visibly changed nothing on three of them,\n\t\t\t\t\t\t while still re-keying the fetch cache — a live control in the masthead\n\t\t\t\t\t\t that does nothing is worse than no control, because the reader concludes\n\t\t\t\t\t\t the tool is unresponsive rather than that the setting is irrelevant here. */}\n\t\t\t\t\t\t{COMPARED_TABS.includes(activePanel) && <BasisSelector value={compare} onChange={setCompare} />}\n\t\t\t\t\t</Stack>\n\t\t\t\t</Flex>\n\n\t\t\t\t<PanelTabs value={activePanel} onChange={setActivePanel} />\n\n\t\t\t\t{/* The constraint, stated where it applies, on every panel.\n\t\t\t\t Once a span can be dragged, a reader is regularly in a window nobody chose from\n\t\t\t\t a menu — and the only indication of it was a footer line below all the content. */}\n\t\t\t\t{range === 'custom' && (\n\t\t\t\t\t<div style={contextRow}>\n\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t{/* Not always \"narrowed\": a custom range can be wider than a week, up to the\n\t\t\t\t\t\t\t two-year cap. */}\n\t\t\t\t\t\t\tShowing {custom.start} to {custom.end} ({daysBetween(custom.start, custom.end)} days).\n\t\t\t\t\t\t\t{daysBetween(custom.start, custom.end) < 14 && (\n\t\t\t\t\t\t\t\t<> Short windows lose GA4’s low-count rows, so the source and typeface\n\t\t\t\t\t\t\t\tlists will be shorter than reality.</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t{/* Stepping, because a brush only ever goes inward. Every selection is drawn on\n\t\t\t\t\t\t the current window, so successive brushes narrow and narrow again with no\n\t\t\t\t\t\t way to widen, pan, or ask the obvious follow-up — \"was the week before\n\t\t\t\t\t\t like this?\" The span shifts by its own length, which is the same window\n\t\t\t\t\t\t the server already computes for the comparison. */}\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tstyle={inlineClear}\n\t\t\t\t\t\t\taria-label=\"Shift the window back by its own length\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tconst span = daysBetween(custom.start, custom.end)\n\t\t\t\t\t\t\t\tsetCustom({ start: shiftDays(custom.start, -span), end: shiftDays(custom.end, -span) })\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t← earlier\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{/* Disabled at the present, rather than allowed to overshoot it. The server\n\t\t\t\t\t\t rejects a future end date with a 400, which the panel renders as a critical\n\t\t\t\t\t\t card IN PLACE OF ALL CONTENT — including the timeline you would use to get\n\t\t\t\t\t\t back. A navigation control should not be able to put the tool into an error\n\t\t\t\t\t\t state the server already knows is unreachable. */}\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\taria-label=\"Shift the window forward by its own length\"\n\t\t\t\t\t\t\tdisabled={atPresent}\n\t\t\t\t\t\t\t// `inlineClear` hard-sets colour, opacity and cursor, so the UA's disabled\n\t\t\t\t\t\t\t// greying never applied: a disabled control was pixel-identical to the live\n\t\t\t\t\t\t\t// one beside it and its only explanation was a title, which disabled elements\n\t\t\t\t\t\t\t// suppress in several browsers and which does not exist on touch.\n\t\t\t\t\t\t\tstyle={atPresent ? { ...inlineClear, opacity: 0.35, cursor: 'not-allowed' } : inlineClear}\n\t\t\t\t\t\t\ttitle={atPresent ? 'This window already ends today' : undefined}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tconst span = daysBetween(custom.start, custom.end)\n\t\t\t\t\t\t\t\tconst today = isoDaysAgo(0)\n\t\t\t\t\t\t\t\t// `daysBetween` is INCLUSIVE — it adds one — so it is the wrong function\n\t\t\t\t\t\t\t\t// for a delta. Using it for the remaining room shifted the window one day\n\t\t\t\t\t\t\t\t// PAST today, producing exactly the future-dated 400 this clamp exists to\n\t\t\t\t\t\t\t\t// prevent, and made the `<= 0` guard unreachable.\n\t\t\t\t\t\t\t\tconst room = daysBetween(custom.end, today) - 1\n\t\t\t\t\t\t\t\tconst allowed = Math.min(span, room)\n\t\t\t\t\t\t\t\tif (allowed <= 0) return\n\t\t\t\t\t\t\t\tsetCustom({ start: shiftDays(custom.start, allowed), end: shiftDays(custom.end, allowed) })\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tlater →\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button type=\"button\" style={inlineClear} onClick={() => setRange(rangeBeforeBrush)}>\n\t\t\t\t\t\t\tBack to the {RANGES.find((r) => r.key === rangeBeforeBrush)?.label.toLowerCase() ?? 'week'}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t<Box\n\t\t\t\t\trole=\"tabpanel\"\n\t\t\t\t\tid={`panel-${activePanel}`}\n\t\t\t\t\taria-labelledby={`tab-${activePanel}`}\n\t\t\t\t\t// Focusable so a keyboard user can Tab straight from the tab into its content.\n\t\t\t\t\ttabIndex={0}\n\t\t\t\t\tstyle={{ position: 'relative' }}\n\t\t\t\t>\n\t\t\t\t\t<Stack space={4}>\n\t\t\t\t\t\t{/* Keyed on the tab, so a throw on one panel does not render the error card for\n\t\t\t\t\t\t every other. Without the key the boundary held `error` forever and its own\n\t\t\t\t\t\t copy — \"The other panels are unaffected\" — became false. */}\n\t\t\t\t\t\t<PanelBoundary resetKey={`${activePanel}|${range}|${custom.start}|${custom.end}`}>\n\t\t\t\t\t\t\t<ReportPanel\n\t\t\t\t\t\t\t\ttabId={active?.id ?? 'overview'}\n\t\t\t\t\t\t\t\treport={active?.report ?? 'measurement-health'}\n\t\t\t\t\t\t\t\tapiBaseUrl={apiBaseUrl}\n\t\t\t\t\t\t\t\trange={range}\n\t\t\t\t\t\t\t\tcustom={custom}\n\t\t\t\t\t\t\t\tcompare={compare}\n\t\t\t\t\t\t\t\tonBrush={(start, end) => { if (range !== 'custom') setRangeBeforeBrush(range); setCustom({ start, end }); setRange('custom') }}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</PanelBoundary>\n\t\t\t\t\t</Stack>\n\t\t\t\t</Box>\n\t\t\t</Stack>\n\t\t</Container>\n\t)\n}\n","/**\n * Data hook for the Studio panels.\n *\n * Forwards the Studio's own Sanity session token to the site's API route, which verifies it against\n * Sanity. There is no shared secret: the Studio bundle is public, so anything compiled into it is\n * extractable.\n */\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { useClient } from 'sanity'\nimport type { RangeKey, ReportEnvelope, ReportName } from '../types'\n\n/**\n * Envelopes already fetched this session, keyed by what identifies them.\n *\n * Module-level and deliberately unbounded within a session: there are five reports and a handful of\n * ranges, so the ceiling is tens of entries. Without it every tab visit was a fresh round trip —\n * Acquisition to Journey and back paid twice and waited twice — and the server's own cache is\n * per-instance with a five-minute TTL, so a cold serverless instance re-fanned-out to GA4, Vercel,\n * Sanity and Mailchimp to answer a question it had answered a moment earlier.\n *\n * Served immediately, then revalidated behind, so a return trip is a view change rather than a\n * page load. That is most of what makes five tabs feel like one dataset.\n */\nconst envelopeCache = new Map<string, ReportEnvelope<unknown>>()\n\n/**\n * The last GA4 shortfall seen for a given site and window, so every tab can know it.\n *\n * `shortfallRatio` is computed by one report. The other three panels are built entirely from GA4\n * and had no access to it at any position on the page, so the tool could print \"GA4 is seeing 20%\n * of your traffic — treat its figures as broken\" on one tab and then render `Sessions 357` in the\n * largest available type on the next, with nothing anywhere saying it was a fifth of reality. A\n * source that answers confidently and wrongly is `status: 'ok'`, so the source row cannot catch it.\n *\n * Keyed by site and window because the shortfall is a property of both — carrying last week's\n * figure onto a quarter view would be a different lie.\n */\nconst shortfallByWindow = new Map<string, number>()\n\n/** The window part of a cache key: the named range, or the custom bounds. */\nfunction windowKey(range: string, custom?: { start: string; end: string }): string {\n\treturn range === 'custom' && custom ? `${custom.start}..${custom.end}` : range\n}\n\n/**\n * What is known about GA4's coverage of this site and window, or null when nothing is.\n *\n * Null is a real answer and must render as nothing: a panel that cannot say how lossy its source is\n * should not imply the source is fine.\n *\n * @param base - the site's API base URL, as passed to the tool\n * @param range - the range name currently selected\n * @param custom - the custom bounds, when the range is custom\n */\nexport function knownShortfall(base: string, range: string, custom?: { start: string; end: string }): number | null {\n\treturn shortfallByWindow.get(`${base}|${windowKey(range, custom)}`) ?? null\n}\n\n/**\n * Record a shortfall an envelope carried, if it carried one.\n *\n * Exported for its tests, not for consumers — deliberately absent from `src/index.ts`, so it is a\n * module seam rather than public API.\n */\nexport function rememberShortfall(base: string, range: string, custom: { start: string; end: string } | undefined, data: unknown): void {\n\tconst ratio = (data as { shortfallRatio?: unknown } | null)?.shortfallRatio\n\t// Bounded on both sides: a ratio outside 0..1 is a bug upstream, not a coverage figure.\n\tif (typeof ratio === 'number' && Number.isFinite(ratio) && ratio >= 0 && ratio <= 1) {\n\t\tshortfallByWindow.set(`${base}|${windowKey(range, custom)}`, ratio)\n\t}\n}\n\n/** Identity of a request: everything that changes the answer. */\nfunction cacheKey(base: string, report: string, range: string, custom?: { start: string; end: string }, compare?: string): string {\n\t// apiBaseUrl included: the plugin takes a `name` so it can be registered twice in one Studio,\n\t// and without the base those two registrations would serve each other's envelopes.\n\t// The comparison basis is part of the key: two bases produce different envelopes for the same\n\t// window, and without it switching baseline would serve the other one's deltas from cache.\n\treturn [base, report, range, custom?.start ?? '', custom?.end ?? '', compare ?? 'previous-period'].join('|')\n}\n\n/**\n * Whether a previous answer may stay on screen while the next one loads.\n *\n * Only for the same report. Held across a RANGE change, which is the point — a reader must be able\n * to change one variable without losing their sort, their filter and their place. NOT held across\n * a REPORT change: that handed one panel another's payload, which does not crash, because every\n * field is guarded with `?? []`, and therefore silently drew an empty funnel. Wrong content is\n * worse than a spinner, and unlike a spinner it looks like an answer.\n *\n * Exported as a pure predicate so it can be tested without a DOM, which this package does not have.\n *\n * @param current - the state on screen right now\n * @param nextReport - the report about to be fetched\n */\nexport function holdsPreviousAnswer<T>(current: ReportState<T>, nextReport: ReportName): boolean {\n\treturn current.status === 'ready' && current.envelope.report === nextReport\n}\n\n/** Sanity API version this tool pins. Fixed rather than \"latest\" so behaviour cannot drift. */\nconst API_VERSION = '2024-03-01'\n\n/** Loading state for a report. */\nexport type ReportState<T> =\n\t| { status: 'idle' }\n\t| { status: 'loading' }\n\t| { status: 'ready'; envelope: ReportEnvelope<T>; stale?: boolean; revalidationError?: string }\n\t// `disabled` separates \"this site has visitor insights switched off\" from \"something broke\".\n\t// They are both non-200s, but only one of them is worth a retry button.\n\t| { status: 'error'; message: string; disabled?: boolean }\n\n/** Options for useReport. */\nexport interface UseReportOptions {\n\t/** Explicit dates, required when `range` is 'custom' and ignored otherwise. */\n\tcustom?: { start: string; end: string }\n\t/** Base URL of the site serving the reports, e.g. `https://dardenstudio.com`. */\n\tapiBaseUrl: string\n\treport: ReportName\n\trange: RangeKey\n\t/** Which baseline the deltas are measured against. Defaults to the preceding window. */\n\tcompare?: 'previous-period' | 'same-period-last-year'\n\t/**\n\t * Whether to fetch at all. Defaults to true.\n\t *\n\t * Lets a panel declare a secondary report it only needs on one tab, without the other four\n\t * paying a request for data they will not draw.\n\t */\n\tenabled?: boolean\n}\n\n/**\n * Fetch a report, re-fetching when the range changes.\n *\n * @returns the current state plus a `reload` for manual refresh\n */\nexport function useReport<T>({ apiBaseUrl, report, range, custom, compare, enabled = true }: UseReportOptions): {\n\tstate: ReportState<T>\n\treload: () => void\n} {\n\tconst client = useClient({ apiVersion: API_VERSION })\n\tconst [state, setState] = useState<ReportState<T>>({ status: 'idle' })\n\tconst [nonce, setNonce] = useState(0)\n\n\t// Lets an in-flight response from a previous range be discarded rather than overwriting a newer one.\n\tconst requestIdRef = useRef(0)\n\n\tconst reload = useCallback(() => setNonce((n) => n + 1), [])\n\n\tuseEffect(() => {\n\t\tif (!enabled) return\n\t\tconst requestId = requestIdRef.current + 1\n\t\trequestIdRef.current = requestId\n\n\t\tconst controller = new AbortController()\n\t\tconst key = cacheKey(apiBaseUrl, report, range, custom, compare)\n\t\tconst cached = envelopeCache.get(key)\n\n\t\t// A cached answer for THIS key is shown at once and still revalidated. Marked stale so the\n\t\t// reader knows a fresher one is coming, rather than being told nothing and wondering.\n\t\tif (cached) setState({ status: 'ready', envelope: cached as ReportEnvelope<T>, stale: true })\n\n\t\t// Keep showing the last answer while fetching the next one, marked stale.\n\t\t//\n\t\t// This used to clear to `loading` unconditionally, which unmounted the ready subtree and\n\t\t// took every table's sort, filter and exclusions with it. Widen Week to Month to check\n\t\t// whether a ranking holds and the ranking is gone, after the screen went blank — so a reader\n\t\t// could never hold a question steady while changing one variable, which is the whole of\n\t\t// what \"explorable\" means. It also dumped keyboard focus to the body on every range change.\n\t\t// Only a previous answer FOR THE SAME REPORT may be held over. Without that check, switching\n\t\t// tabs with nothing cached kept the old envelope and handed it to the new panel — Journey\n\t\t// rendered with Acquisition's payload, which does not crash (every field is guarded with\n\t\t// `?? []`) and therefore silently drew an empty funnel instead. Wrong content is worse than\n\t\t// a spinner, and unlike a spinner it looks like an answer.\n\t\telse {\n\t\t\tsetState((current) => (\n\t\t\t\tholdsPreviousAnswer(current, report) ? { ...current, stale: true } : { status: 'loading' }\n\t\t\t))\n\t\t}\n\n\t\tasync function run() {\n\t\t\t// The Studio client carries the session token under token-based auth. Under cookie-based\n\t\t\t// auth it does not, and there is no way to forward credentials the browser will not\n\t\t\t// expose — so say so plainly rather than failing with an opaque 401.\n\t\t\tconst token = client.config().token\n\t\t\tif (!token) {\n\t\t\t\tsetState({\n\t\t\t\t\tstatus: 'error',\n\t\t\t\t\tmessage: 'No Sanity session token available in this Studio. Visitor Insights needs token-based auth to call the site API.',\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\t// Coerced rather than assumed. An empty base is legitimate — it means the Studio is\n\t\t\t\t// served from the same origin as the site — but an undefined one used to reach\n\t\t\t\t// .replace() directly and crash every panel with a stack trace instead of a\n\t\t\t\t// message. Treating a missing base as same-origin degrades to the common case.\n\t\t\t\tconst base = typeof apiBaseUrl === 'string' ? apiBaseUrl.replace(/\\/$/, '') : ''\n\t\t\t\t// Custom ranges carry their own dates. Built with URLSearchParams so a date can\n\t\t\t\t// never break the query string, and so the two named-range and custom-range paths\n\t\t\t\t// produce one shape rather than two.\n\t\t\t\tconst query = new URLSearchParams({ range })\n\t\t\t\t// Only sent when it differs from the default, so an unchanged request keeps the exact\n\t\t\t\t// URL — and therefore the exact cache key — it had before this option existed.\n\t\t\t\tif (compare && compare !== 'previous-period') query.set('compare', compare)\n\t\t\t\tif (range === 'custom' && custom) {\n\t\t\t\t\tquery.set('start', custom.start)\n\t\t\t\t\tquery.set('end', custom.end)\n\t\t\t\t}\n\t\t\t\tconst url = `${base}/api/visitor-insights/${report}?${query.toString()}`\n\t\t\t\tconst response = await fetch(url, {\n\t\t\t\t\theaders: { Authorization: `Bearer ${token}` },\n\t\t\t\t\tsignal: controller.signal,\n\t\t\t\t})\n\n\t\t\t\tif (requestIdRef.current !== requestId) return\n\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\t// A 400 is the handler rejecting the request itself — an inverted or oversized\n\t\t\t\t\t// custom range, most often — and a 503 is the site's master switch being off.\n\t\t\t\t\t// Both carry a message written for the reader. Anything else may echo upstream\n\t\t\t\t\t// detail, so only the status crosses over.\n\t\t\t\t\tlet detail = response.status === 401\n\t\t\t\t\t\t? 'Not authorised — sign in to the Studio again.'\n\t\t\t\t\t\t: `Request failed (${response.status})`\n\t\t\t\t\tlet disabled = false\n\t\t\t\t\tif (response.status === 400 || response.status === 503) {\n\t\t\t\t\t\tconst body = (await response.json().catch(() => null)) as { error?: string; disabled?: boolean } | null\n\t\t\t\t\t\tif (body?.error) detail = body.error\n\t\t\t\t\t\tdisabled = body?.disabled === true\n\t\t\t\t\t}\n\t\t\t\t\tif (requestIdRef.current !== requestId) return\n\t\t\t\t\tsetState({ status: 'error', message: detail, disabled })\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tconst envelope = (await response.json()) as ReportEnvelope<T>\n\t\t\t\tif (requestIdRef.current !== requestId) return\n\n\t\t\t\tenvelopeCache.set(key, envelope as ReportEnvelope<unknown>)\n\t\t\t\trememberShortfall(apiBaseUrl, range, custom, envelope.data)\n\t\t\t\tsetState({ status: 'ready', envelope })\n\t\t\t} catch (e) {\n\t\t\t\tif (controller.signal.aborted || requestIdRef.current !== requestId) return\n\t\t\t\t// A failed REVALIDATION must not destroy the figures already on screen. It used to\n\t\t\t\t// replace a readable panel with a red card, and \"Try again\" flashed the data back\n\t\t\t\t// and then the error again. The last good answer stays; the failure is reported\n\t\t\t\t// beside it.\n\t\t\t\tsetState((current) => (\n\t\t\t\t\tcurrent.status === 'ready'\n\t\t\t\t\t\t? { ...current, stale: false, revalidationError: (e as Error).message }\n\t\t\t\t\t\t: { status: 'error', message: (e as Error).message }\n\t\t\t\t))\n\t\t\t}\n\t\t}\n\n\t\tvoid run()\n\t\treturn () => controller.abort()\n\t// custom.start/end by value rather than the object: the caller builds a fresh object on every\n\t// render, and depending on its identity would refetch on each keystroke in the date fields.\n\t//\n\t// `compare` belongs here and was missing. The effect READS it — it goes into the query string and\n\t// into the cache key — so leaving it out meant choosing \"Last year\" selected the button and\n\t// fetched nothing: every delta on screen stayed measured against the previous period until some\n\t// other dependency happened to change. Not an inert control but a lying one, and the whole point\n\t// of giving the basis toggle its own colour was to tell the reader which window they were\n\t// looking at.\n\t}, [client, apiBaseUrl, report, range, custom?.start, custom?.end, compare, nonce, enabled])\n\n\treturn { state, reload }\n}\n\n/** Drop every remembered shortfall. For tests, so one case cannot leak into the next. */\nexport function forgetShortfalls(): void {\n\tshortfallByWindow.clear()\n}\n","/**\n * The tool's view, in the URL — so a finding can be sent to someone.\n *\n * Every piece of state here was component-local: tab, range, and the custom window. A Studio\n * reload, a browser back, or an accidental navigation discarded the whole investigation, and\n * \"Acquisition, 24 August to 7 September\" — the exact view in which someone notices the thing worth\n * noticing — could not be handed to a colleague or returned to by the person who found it. There is\n * a \"Copy as CSV\" in this tool precisely because sharing a finding is the real task; the view\n * itself was the one thing that could not be shared.\n *\n * The HASH, not the path or the query. Sanity's own router owns the path, and writing to it would\n * fight the Studio for control of navigation; the hash is unclaimed, survives a reload, is carried\n * by a copied link, and is ignored by every server. The value is namespaced because other tools in\n * the same Studio may want the same trick.\n *\n * Everything here is a pure string transformation, so it is testable without a DOM — which matters,\n * because the alternative is a feature whose only proof is clicking around a deployed Studio.\n */\n\n/** The part of the tool's state worth putting in a link. */\nexport interface ViewState {\n\t/** Tab id, e.g. `overview`. */\n\ttab?: string\n\t/** Range key, e.g. `week` or `custom`. */\n\trange?: string\n\t/** Custom window start, ISO date. Only meaningful when `range` is `custom`. */\n\tfrom?: string\n\t/** Custom window end, ISO date. */\n\tto?: string\n}\n\n/** Our namespace within the hash, so another tool's state is left alone. */\nconst KEY = 'insights'\n\n/** An ISO calendar date, and nothing else — this value reaches a report request. */\nconst ISO_DATE = /^\\d{4}-\\d{2}-\\d{2}$/\n\n/**\n * Identifiers we will echo back into the URL.\n *\n * Tab and range ids are matched against known lists by the caller, but this is the first gate: the\n * hash is attacker-controllable in the sense that anyone can send a link, so nothing shaped\n * unexpectedly gets as far as being compared, stored, or rendered.\n */\nconst IDENTIFIER = /^[a-z][a-z0-9-]{0,31}$/\n\n/**\n * Serialise a view to the hash fragment it should occupy.\n *\n * Returns an empty string for an empty view, so a default view leaves the URL clean rather than\n * decorating it with state nobody set.\n *\n * @param view - the state to encode\n */\nexport function encodeView(view: ViewState): string {\n\tconst parts: string[] = []\n\tif (view.tab && IDENTIFIER.test(view.tab)) parts.push(`tab:${view.tab}`)\n\tif (view.range && IDENTIFIER.test(view.range)) parts.push(`range:${view.range}`)\n\t// The window is only carried when it is the one in use. A stale custom range riding along in\n\t// every link would reopen someone else's window when they clicked a link about the week.\n\tif (view.range === 'custom') {\n\t\tif (view.from && ISO_DATE.test(view.from)) parts.push(`from:${view.from}`)\n\t\tif (view.to && ISO_DATE.test(view.to)) parts.push(`to:${view.to}`)\n\t}\n\treturn parts.length > 0 ? `${KEY}=${parts.join(';')}` : ''\n}\n\n/**\n * Read a view out of a hash fragment.\n *\n * Every field is validated and an unrecognised one is dropped rather than defaulted, so a truncated\n * or hand-edited link degrades to whatever it could parse instead of failing or, worse, silently\n * requesting a window nobody chose.\n *\n * @param hash - `window.location.hash`, with or without its leading `#`\n */\nexport function decodeView(hash: string): ViewState {\n\tconst raw = hash.startsWith('#') ? hash.slice(1) : hash\n\t// Other tools' fragments live alongside ours, separated by `&` as in a query string.\n\tconst mine = raw.split('&').find((part) => part.startsWith(`${KEY}=`))\n\tif (!mine) return {}\n\n\tconst view: ViewState = {}\n\tfor (const pair of mine.slice(KEY.length + 1).split(';')) {\n\t\tconst separator = pair.indexOf(':')\n\t\tif (separator < 1) continue\n\t\tconst name = pair.slice(0, separator)\n\t\tconst value = pair.slice(separator + 1)\n\t\tif (name === 'tab' && IDENTIFIER.test(value)) view.tab = value\n\t\telse if (name === 'range' && IDENTIFIER.test(value)) view.range = value\n\t\telse if (name === 'from' && ISO_DATE.test(value)) view.from = value\n\t\telse if (name === 'to' && ISO_DATE.test(value)) view.to = value\n\t}\n\n\t// A custom range needs both ends. One alone would resolve against a default the sender never\n\t// saw, which is a different window wearing the same link.\n\tif (view.range === 'custom' && !(view.from && view.to)) {\n\t\tdelete view.from\n\t\tdelete view.to\n\t\tdelete view.range\n\t}\n\n\treturn view\n}\n\n/**\n * Replace our fragment in a hash, preserving anyone else's.\n *\n * @param hash - the current hash\n * @param view - the state to write\n */\nexport function mergeIntoHash(hash: string, view: ViewState): string {\n\tconst raw = hash.startsWith('#') ? hash.slice(1) : hash\n\tconst others = raw.split('&').filter((part) => part.length > 0 && !part.startsWith(`${KEY}=`))\n\tconst mine = encodeView(view)\n\tconst all = mine ? [...others, mine] : others\n\treturn all.length > 0 ? `#${all.join('&')}` : ''\n}\n","/**\n * Date-range resolution.\n *\n * Every range is resolved against one declared timezone — the GA4 property's — because the three\n * sources bucket time differently: GA4 by the property timezone, Vercel by UTC, Sanity `_createdAt`\n * by UTC. Without a single anchor, an order placed near midnight lands in different weeks depending\n * on which source you ask, which corrupts exactly the cross-source comparison this tool exists for.\n */\n\nimport type { DateRange, RangeKey } from '../types'\n\n/** GA4 does not finalise the most recent days. Figures inside this window may still rise. */\nexport const GA4_PROCESSING_LAG_DAYS = 2\n\n/** Format a Date as `YYYY-MM-DD` in the given IANA timezone. */\nexport function formatInTimeZone(date: Date, timeZone: string): string {\n\t// en-CA gives ISO-ordered parts, which is what we want to reassemble.\n\tconst parts = new Intl.DateTimeFormat('en-CA', {\n\t\ttimeZone,\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t}).formatToParts(date)\n\n\tconst get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''\n\treturn `${get('year')}-${get('month')}-${get('day')}`\n}\n\n/**\n * How far the given instant's wall clock in `timeZone` sits from UTC, in milliseconds.\n *\n * @param date - the instant to measure at; the offset changes across DST boundaries\n */\nfunction zoneOffsetMs(date: Date, timeZone: string): number {\n\tconst parts = new Intl.DateTimeFormat('en-CA', {\n\t\ttimeZone,\n\t\thourCycle: 'h23',\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t\thour: '2-digit',\n\t\tminute: '2-digit',\n\t\tsecond: '2-digit',\n\t}).formatToParts(date)\n\n\tconst get = (type: string) => Number(parts.find((p) => p.type === type)?.value)\n\tconst asIfUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'))\n\treturn asIfUtc - date.getTime()\n}\n\n/**\n * The UTC instant at which a calendar day begins in a timezone.\n *\n * Orders are stored as UTC `_createdAt`, and every range in this tool is a set of calendar dates in\n * the GA4 property's timezone. Bounding an order query with a bare `${date}T00:00:00Z` therefore\n * asks for a different window than the one GA4 was asked for — seven or eight hours out for a\n * US-Pacific property. At a handful of orders a day that is a visible swing in a figure the panel\n * presents as exact, and it always moved orders in one direction, never both.\n *\n * @param isoDate - `YYYY-MM-DD` in `timeZone`\n * @param timeZone - IANA timezone\n * @returns an ISO 8601 instant in UTC\n */\nexport function zonedDayStartUtc(isoDate: string, timeZone: string): string {\n\tconst naive = Date.parse(`${isoDate}T00:00:00Z`)\n\tif (Number.isNaN(naive)) throw new Error(`Invalid ISO date: ${isoDate}`)\n\n\t// Two passes: the first guess can land on the wrong side of a DST transition, in which case its\n\t// offset is the wrong one to subtract. Re-measuring at the corrected instant settles it for\n\t// every zone whose transition is away from local midnight — which is all of them in practice,\n\t// including both foundry zones, and verified across spring-forward and fall-back and for\n\t// non-hour offsets (Kolkata +05:30, Kathmandu +05:45, Chatham +12:45/+13:45).\n\t//\n\t// It does NOT settle for a zone that transitions AT local midnight: the second pass measures\n\t// the offset at the transition instant itself and never converges, leaving the day boundary an\n\t// hour out in either direction (Santiago, Havana, Beirut, Cairo). Nothing double-counts,\n\t// because day N's end is day N+1's start by construction, so the window merely shifts by an\n\t// hour against the one GA4 was asked for. None of the three foundries uses such a zone; this is\n\t// recorded rather than fixed because a third pass does not converge either — it needs the\n\t// transition table, not another probe.\n\tlet instant = naive - zoneOffsetMs(new Date(naive), timeZone)\n\tinstant = naive - zoneOffsetMs(new Date(instant), timeZone)\n\treturn new Date(instant).toISOString()\n}\n\n/**\n * The UTC instant at which a calendar day ends in a timezone, exclusive.\n *\n * Exclusive rather than 23:59:59 so the last second of the day cannot be dropped, and so a day\n * that is 23 or 25 hours long across a DST change is still bounded correctly.\n *\n * @param isoDate - `YYYY-MM-DD` in `timeZone`\n * @param timeZone - IANA timezone\n */\nexport function zonedDayEndUtc(isoDate: string, timeZone: string): string {\n\treturn zonedDayStartUtc(shiftDays(isoDate, 1), timeZone)\n}\n\n/** Shift an ISO `YYYY-MM-DD` date by a whole number of days. */\nexport function shiftDays(isoDate: string, days: number): string {\n\tconst time = Date.parse(`${isoDate}T00:00:00Z`)\n\tif (Number.isNaN(time)) throw new Error(`Invalid ISO date: ${isoDate}`)\n\treturn new Date(time + days * 86_400_000).toISOString().slice(0, 10)\n}\n\n/** Inclusive whole days between two ISO dates. */\nexport function daysBetween(start: string, end: string): number {\n\treturn Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000) + 1\n}\n\n/** How many days each range key spans. */\n/**\n * What a period-over-period change is measured against.\n *\n * Type sales are seasonal — a release, a conference, a sale — so \"the previous thirty days\" is often\n * the least informative baseline available, and it was the only one. Both cost the same: one extra\n * comparison window either way.\n */\nexport type ComparisonBasis = 'previous-period' | 'same-period-last-year'\n\n/** Trailing window length in days, for the named ranges. `custom` carries its own dates. */\nexport const RANGE_DAYS: Record<Exclude<RangeKey, 'custom'>, number> = {\n\tweek: 7,\n\tmonth: 30,\n\tquarter: 91,\n\tyear: 365,\n}\n\n/**\n * Longest custom range accepted.\n *\n * Two years, because GA4 event-level retention tops out at 14 months on most properties and Vercel\n * Web Analytics at 12 to 24 depending on plan — beyond that both sources return silence that would\n * read as a collapse. The cap also bounds the cost of a single request.\n */\nexport const MAX_CUSTOM_RANGE_DAYS = 730\n\n/**\n * Resolve a range key into concrete dates, anchored to `timezone` and ending today.\n *\n * @param key - week, quarter or year\n * @param timezone - IANA timezone of the GA4 property this range will be queried against\n * @param now - injectable clock, so tests are deterministic\n */\nexport function resolveRange(key: Exclude<RangeKey, 'custom'>, timezone: string, now: Date = new Date()): DateRange {\n\tconst end = formatInTimeZone(now, timezone)\n\tconst start = shiftDays(end, -(RANGE_DAYS[key] - 1))\n\treturn { key, start, end, timezone }\n}\n\n/**\n * The equivalent range immediately before `range`, for period-over-period comparison.\n *\n * A bare count answers \"how many\", which on its own is not actionable; the comparison is what\n * tells someone whether to do anything.\n */\nexport function previousRange(range: DateRange, basis: ComparisonBasis = 'previous-period'): DateRange {\n\tif (basis === 'same-period-last-year') {\n\t\t/*\n\t\t * The same calendar window a year earlier.\n\t\t *\n\t\t * Shifted by 364 days rather than by a calendar year, so the window keeps its shape: a\n\t\t * Monday-to-Sunday week stays Monday-to-Sunday, and a foundry comparing a release week\n\t\t * against last year's is not comparing a weekend against a working week. It costs one day of\n\t\t * drift a year, which is worth far less than the weekday alignment.\n\t\t */\n\t\treturn {\n\t\t\tkey: range.key,\n\t\t\tstart: shiftDays(range.start, -364),\n\t\t\tend: shiftDays(range.end, -364),\n\t\t\ttimezone: range.timezone,\n\t\t}\n\t}\n\n\tconst span = daysBetween(range.start, range.end)\n\treturn {\n\t\tkey: range.key,\n\t\tstart: shiftDays(range.start, -span),\n\t\tend: shiftDays(range.end, -span),\n\t\ttimezone: range.timezone,\n\t}\n}\n\n/**\n * The trailing dates within a range whose GA4 figures are not yet settled.\n * Returns an empty array when the range ends before the lag window.\n */\nexport function provisionalDates(range: DateRange, now: Date = new Date()): string[] {\n\tconst today = formatInTimeZone(now, range.timezone)\n\tconst dates: string[] = []\n\n\tfor (let i = 0; i < GA4_PROCESSING_LAG_DAYS; i += 1) {\n\t\tconst date = shiftDays(today, -i)\n\t\tif (date >= range.start && date <= range.end) dates.push(date)\n\t}\n\n\treturn dates.sort()\n}\n\n/**\n * Notice text when a range includes days GA4 has not finished processing, or `null` when it does not.\n * Without this a Monday-morning glance at \"this week\" reads the trailing dip as a real drop.\n */\nexport function provisionalNotice(range: DateRange, now: Date = new Date()): string | null {\n\tconst dates = provisionalDates(range, now)\n\tif (dates.length === 0) return null\n\n\tconst from = dates[0]\n\treturn `GA4 has not finished processing ${from} onwards; those figures are provisional and will rise.`\n}\n\n/** Why a custom range was refused, in words a reader can act on. */\nexport type CustomRangeError = string\n\n/**\n * Build a range from explicit dates, or explain why they cannot be used.\n *\n * Validated here rather than at the handler because the same rules apply wherever a range comes\n * from, and because a bad range must never reach GA4 as a silently different query — an inverted\n * start and end returns an empty report, which renders as a site with no traffic.\n *\n * @param start - inclusive ISO date, YYYY-MM-DD\n * @param end - inclusive ISO date\n * @param timezone - IANA timezone of the property, so \"today\" means today there\n * @param now - injectable clock\n */\nexport function resolveCustomRange(\n\tstart: string,\n\tend: string,\n\ttimezone: string,\n\tnow: Date = new Date(),\n): { range: DateRange } | { error: CustomRangeError } {\n\tconst ISO = /^\\d{4}-\\d{2}-\\d{2}$/\n\tif (!ISO.test(start) || !ISO.test(end)) {\n\t\treturn { error: 'Dates must be written as YYYY-MM-DD.' }\n\t}\n\n\tconst startMs = Date.parse(`${start}T00:00:00Z`)\n\tconst endMs = Date.parse(`${end}T00:00:00Z`)\n\tif (!Number.isFinite(startMs) || !Number.isFinite(endMs)) {\n\t\treturn { error: 'That is not a real date.' }\n\t}\n\n\tif (endMs < startMs) {\n\t\treturn { error: 'The end date is before the start date.' }\n\t}\n\n\t// \"Today\" in the property's timezone, not the reader's. A Studio open in London asking a\n\t// New York property for today would otherwise be refused for half the day.\n\tconst today = formatInTimeZone(now, timezone)\n\tif (end > today) {\n\t\treturn { error: `The end date is in the future. The latest data available is ${today}.` }\n\t}\n\n\tconst days = Math.round((endMs - startMs) / 86400000) + 1\n\tif (days > MAX_CUSTOM_RANGE_DAYS) {\n\t\treturn { error: `Ranges are limited to ${MAX_CUSTOM_RANGE_DAYS} days. Both sources stop returning data well before that.` }\n\t}\n\n\treturn { range: { key: 'custom', start, end, timezone } }\n}\n\n/**\n * How many days after a send its orders are counted for.\n *\n * Three. A newsletter's clicks arrive within hours and its orders within a day or two; stretching\n * the window further does not attribute more sales to the send, it attributes more of the site's\n * ordinary trade to it. The window closes early when the next send lands, so overlapping sends\n * never claim the same order.\n */\nexport const SEND_WINDOW_DAYS = 3\n","/**\n * Shared renderers for metric values and comparison bars.\n *\n * Two rules are enforced here rather than left to each panel:\n *\n * 1. An unavailable metric renders as an em dash with a stated reason — never as \"0\". A missing\n * measurement and a measured zero mean opposite things, and must never look alike.\n * 2. Nothing is distinguished by colour alone. Bars carry a text value, a percentage and an\n * accessible label, so the figure survives greyscale, colour blindness and both Studio themes.\n */\n\nimport React, { useRef, useState } from 'react'\nimport { Badge, Box, Card, Flex, Heading, Stack, Text, Tooltip } from '@liiift-studio/sanity-ui-compat'\nimport { COMPARISON_TEXT, SERIES, mark } from './palette'\nimport type { MetricValue, UnavailableReason } from '../types'\nimport { valueOrNull } from '../types'\n\n/**\n * The four levels of vertical break, in pixels.\n *\n * Sanity's own scale is [0, 4, 8, 12, 20, 32, …] and this tool used one value from it — `space={3}`,\n * twelve pixels — for sixty-four per cent of all vertical spacing, with `space={4}` at twenty for\n * everything structural. So the largest break in the tool was 1.67 times an ordinary gap between\n * two lines inside a card, and a section boundary looked the same as the next line of a caption.\n * That is what \"under-designed\" meant here: not too little air, too EVEN air.\n *\n * Every step is at LEAST double the one below — 4, 8, 20, 40, so ×2, ×2.5, ×2. That is the property\n * that matters: a level has to be readable at a glance rather than measurable with a ruler, and\n * anything under about ×2 reads as the same level. Twenty rather than sixteen because a card gutter\n * wants the extra room and it is a real token; forty has no token at all — Sanity's scale jumps 32\n * to 52 — which is one more reason these are plain pixels.\n *\n * Plain pixels in `style` rather than `space` props for a second reason: the compat shim's DOM\n * fallback passes `style` through and drops `space`, `padding`, `tone`, `radius` and `border`\n * entirely. On the versions this package supports every primitive resolves, so that is a latent\n * fragility rather than a live fault — but four constants in this file already sidestep it for\n * exactly this reason, and the skeleton was the one part that never did.\n */\nexport const SPACE = { pair: 4, block: 8, group: 20, section: 40 } as const\n\n/**\n * A stack at each level.\n *\n * `display: grid` rather than a column flexbox: identical on one axis, but grid children do not\n * shrink, so a wide table cannot squash the block beside it. The convention is already in this file\n * — `funnelItem` and NoticeList's list both do it.\n */\nexport const stackPair: React.CSSProperties = { display: 'grid', gap: SPACE.pair }\n\n/** Label to figure to note, inside one card. */\nexport const stackBlock: React.CSSProperties = { display: 'grid', gap: SPACE.block }\n\n/** Card to card, check to check, a heading to its own body. */\nexport const stackGroup: React.CSSProperties = { display: 'grid', gap: SPACE.group }\n\n/** One titled region to the next. The only level above twenty. */\nexport const panelStack: React.CSSProperties = { display: 'grid', gap: SPACE.section }\n\n\n/** Human-readable explanation for each unavailable reason. */\nconst REASON_TEXT: Record<UnavailableReason, string> = {\n\tnot_instrumented: 'Not tracked on this site',\n\tbefore_cutover: 'Not tracked during this period',\n\t// \"Withheld ... for privacy\" reads as a legal hold on the reader's own data. What actually\n\t// happened is that too few people did the thing for Google to report it.\n\tsuppressed: 'Google hid it: too few people',\n\toutage: 'Not recorded during part of this period',\n\tsource_error: 'Source did not respond',\n\t// \"Does not apply\" reads as a choice someone made. It is not — there is simply nothing here to\n\t// compute it from.\n\tnot_applicable: 'Nothing here to work this out from',\n\t// Its own reason, because it was borrowing not_applicable — so a figure that is temporarily\n\t// missing rendered \"Does not apply to this site. This site's API route predates this figure.\"\n\t// Two flatly contradictory sentences, and a hurried reader takes the first: this foundry has no\n\t// revenue. That is the mistake the top of this file exists to forbid, one layer up.\n\troute_outdated: 'Not available from this site yet',\n}\n\n/**\n * One locale for every number the tool prints.\n *\n * Counts were hard-coded to en-GB while money passed `undefined` and took the viewer's locale, so\n * on a non-en-GB Studio a single table row read \"1,234\" sessions beside \"1.234,00 $\" — two\n * separator conventions in the same row. Whichever convention is chosen, it has to be the same one\n * on both sides of a comparison.\n */\nconst LOCALE = 'en-GB'\n\n/** Format a number with thousands separators. */\nexport function formatCount(value: number): string {\n\treturn new Intl.NumberFormat(LOCALE).format(Math.round(value))\n}\n\n/** Format a 0–1 ratio as a percentage. */\nexport function formatPercent(ratio: number, digits = 0): string {\n\treturn `${(ratio * 100).toFixed(digits)}%`\n}\n\n/** Props for MetricFigure. */\n/** Format a money value in the site's currency, falling back to a plain number. */\nexport function formatMoney(value: number, currency: string | null, fractionDigits: 0 | 2 = 0): string {\n\tif (!currency) return formatCount(Math.round(value))\n\ttry {\n\t\treturn new Intl.NumberFormat(LOCALE, {\n\t\t\tstyle: 'currency',\n\t\t\tcurrency,\n\t\t\t// One precision for the whole column, chosen by the caller, not by each value's own size.\n\t\t\t// Switching at 1,000 put \"$850.00\" and \"$1,200\" in adjacent rows, and printed an axis\n\t\t\t// reading \"$1,000\" over a baseline reading \"$0.00\" — the mismatch the timeline's own\n\t\t\t// comment claims to have fixed by routing both ends through this function.\n\t\t\tmaximumFractionDigits: fractionDigits,\n\t\t\tminimumFractionDigits: fractionDigits,\n\t\t}).format(value)\n\t} catch {\n\t\t// An unrecognised ISO code must not blank the figure.\n\t\treturn `${formatCount(Math.round(value))} ${currency}`\n\t}\n}\n\n/** Props for Delta. */\nexport interface DeltaProps {\n\t/** This period's value, or null when it could not be measured. */\n\tcurrent: number | null\n\t/** The previous equivalent period's value, or null when there is no comparison. */\n\tprevious: number | null\n\t/**\n\t * Whether a rise is good. Sessions and revenue: yes. A shortfall or a bounce figure: no.\n\t * Drives only the wording and the tone, never whether the number is shown.\n\t */\n\triseIsGood?: boolean\n\t/** Render as a percentage-point change rather than a percentage change of a percentage. */\n\tunit?: 'count' | 'percent'\n}\n\n/**\n * A period-over-period change.\n *\n * Every figure in this tool used to be a bare level, which at these volumes is close to\n * meaningless: \"389 sessions\" is neither good nor bad without last week beside it, and the\n * 24 August collapse would have announced itself on every panel as a delta while going unnoticed\n * for over a week as a level.\n *\n * Renders nothing at all when there is no comparison — an absent delta must never be drawn as\n * \"no change\", which is a different and much more reassuring claim.\n */\n/**\n * How large a baseline must be before a percentage change means anything.\n *\n * Twenty-five. At a foundry's volumes the headline cards routinely compare single digits: seven\n * orders against four rendered \"↑ +75%\" in the largest type on the default tab, where one order\n * moves it twenty-five points. The server layer refuses far better-supported claims than that —\n * it will not rank a typeface on 28 views, and it withholds a funnel rate under a denominator of\n * 30 — while this component had no floor at all and reintroduced the claim in the presentation.\n *\n * Below the floor the figures are still shown; only the PERCENTAGE is withheld. \"7 orders, was 4\"\n * is the whole fact and is not improved by a ratio.\n */\nexport const MIN_DELTA_BASE = 25\n\nexport function Delta({ current, previous, riseIsGood = true, unit = 'count' }: DeltaProps): React.ReactElement | null {\n\t// isFinite, not a null check. An older API route sends undefined for a field it does not know\n\t// about, and a NaN can reach here from a division the server got wrong — both pass `!== null`\n\t// and render \"NaN%\" beside a confident arrow.\n\tif (!Number.isFinite(current as number) || !Number.isFinite(previous as number)) return null\n\tconst now = current as number\n\tconst before = previous as number\n\n\tconst absolute = now - before\n\n\t// Flat means \"rounds to nothing at the precision this prints\", not \"identical to the last bit\".\n\t//\n\t// Only an exact zero counted as flat, so a percent move of 0.04 points printed \"↑ +0.0 pts\" and\n\t// a count move of 2 on 1,000 printed \"↑ 0%\" — an arrow and a direction attached to a magnitude\n\t// of zero, which is a claim the figure has already rounded away. It fires on the Acquisition\n\t// share cards, whose inputs are floats.\n\tconst roundsToNothing = unit === 'percent'\n\t\t? Math.abs(absolute) < 0.05\n\t\t: before !== 0 && Math.abs(absolute / Math.abs(before)) < 0.005\n\tif (absolute === 0 || roundsToNothing) {\n\t\t// Every other branch ends in \"from X\". This one used to stop at two words, so the single\n\t\t// case where a reader most wants to verify the claim was the one case with no number — and\n\t\t// with no arrow either, it read as an ABSENT comparison rather than a measured flat one.\n\t\treturn (\n\t\t\t<span style={deltaStyle()}>\n\t\t\t\tno change<span style={baselineStyle}> from {unit === 'percent' ? `${before.toFixed(1)}%` : formatCount(before)}</span>\n\t\t\t</span>\n\t\t)\n\t}\n\n\t// A change from zero has no defined percentage in either direction. \"+∞%\" is what a naive\n\t// division produces, and asserting `change` non-null let a fall to zero print \"↓ 0%\".\n\tconst change = before === 0 ? null : absolute / Math.abs(before)\n\tconst rising = absolute > 0\n\tconst arrow = rising ? '\\u2191' : '\\u2193'\n\n\t// The baseline is formatted in the figure's own unit. Reading \"Previous period: 43\" under a\n\t// 43.2% consent rate is the same defect MetricFigure's `unit` was added to fix.\n\tconst baseline = unit === 'percent' ? `${before.toFixed(1)}%` : formatCount(before)\n\n\t// Too small a baseline for a ratio to mean anything — see MIN_DELTA_BASE. Placed AFTER the\n\t// zero-baseline case, which has its own wording: a first-ever order is \"new\", not \"was 0\".\n\t// The DIRECTION stays, and stays true; only the percentage is withheld, because at these counts\n\t// the percentage is the part that misleads. Counts only — a percentage-point move on a rate\n\t// carries its own denominator, which this component cannot see.\n\tconst tooFewToRate = change !== null && unit !== 'percent' && Math.abs(before) < MIN_DELTA_BASE\n\n\tconst magnitude = change === null\n\t\t? (rising ? 'new' : 'gone')\n\t\t: tooFewToRate\n\t\t? ''\n\t\t: unit === 'percent'\n\t\t\t// Percentage points, not a percentage of a percentage: a consent rate moving 40% → 44%\n\t\t\t// rose by 4 points, and calling that \"+10%\" is a different and confusing claim.\n\t\t\t? `${rising ? '+' : ''}${absolute.toFixed(1)} pts`\n\t\t\t: `${rising ? '+' : ''}${formatPercent(change, 0)}`\n\n\treturn (\n\t\t// The baseline is printed, not hidden in a `title`. At seven orders a quarter an 18% move\n\t\t// might be one order, so \"from 6\" is the fact and the percentage is the decoration — and a\n\t\t// title attribute is invisible on touch, in a screenshot, and to anyone who does not hover.\n\t\t<span style={deltaStyle()}>\n\t\t\t<span aria-hidden=\"true\">{arrow}</span>\n\t\t\t{magnitude ? ` ${magnitude}` : ''}\n\t\t\t<span style={baselineStyle}> from {baseline}</span>\n\t\t\t{/* Said, rather than left as a gap after the arrow. `magnitude` is empty exactly when the\n\t\t\t baseline is too small to carry a percentage, and \"↑ from 4\" read as a bug. */}\n\t\t\t{tooFewToRate && <span style={baselineStyle}> · too few to rate</span>}\n\t\t</span>\n\t)\n}\n\n/**\n * Delta styling. Direction is carried by the arrow and the words as well as the colour, so the\n * meaning survives a monochrome or colour-blind reading.\n */\nfunction deltaStyle(): React.CSSProperties {\n\t// ONE colour for everything that refers to the other window — see COMPARISON in palette.ts.\n\t//\n\t// This used to vary by tone: opacity 0.9 or 0.55, plus a rule under a bad move and none under a\n\t// good one. Three cards side by side therefore showed three different treatments, and a reader\n\t// reported it as an inconsistency rather than reading it as meaning. It was meaning — but\n\t// meaning already carried twice over, by the arrow and by the sign on the magnitude, so the\n\t// third channel bought nothing and cost the identity.\n\t//\n\t// The underline had a second problem once the text was coloured: a coloured, underlined inline\n\t// span is the browser's hyperlink signature, and these sit on the same screens as real links.\n\t//\n\t// Full alpha is not a preference. COMPARISON clears 3:1 only at alpha 1; the opacity ladder\n\t// this replaces put it at 2.1.\n\treturn {\n\t\tfontFamily: 'inherit',\n\t\tfontSize: '0.8em',\n\t\tfontWeight: 500,\n\t\tcolor: COMPARISON_TEXT,\n\t\twhiteSpace: 'nowrap',\n\t}\n}\n\n/**\n * The baseline inside a delta.\n *\n * Same colour, one step down in weight. Weight alone separates \"the move\" from \"what it moved from\"\n * without breaking the identity or dropping below the contrast floor.\n */\nconst baselineStyle: React.CSSProperties = { color: COMPARISON_TEXT, fontWeight: 400 }\n\n/** Gap between the crosshair and the card, on whichever side it lands. */\nconst HOVER_GUTTER = 14\n\n/** The card's widest possible layout — `hoverCard.maxWidth`, which the flip has to respect. */\nconst HOVER_CARD_MAX = 260\n\nexport function HoverCard({\n\tx,\n\tpaneWidth,\n\tdate,\n\trows,\n\tevents,\n}: {\n\t/** Plot-space x of the crosshair, in CSS pixels. */\n\tx: number\n\t/** The measured pane width, for the flip decision. */\n\tpaneWidth: number\n\t/** The day, already formatted. */\n\tdate: string\n\t/** One entry per series, in the stack's own order. */\n\trows: Array<{ key: string; label: string; color: string; value: string; seen: string | null }>\n\t/** Campaign sends or other markers on this day. */\n\tevents: string[]\n}): React.ReactElement {\n\t// Flip rather than clamp. A clamped card stops tracking the crosshair and then sits ON the\n\t// thing it describes for the whole right-hand edge.\n\t//\n\t// The threshold is the card's own width, not a fraction of the pane. A fixed 0.66 was right on\n\t// a wide pane and wrong on a narrow one: at 400px a crosshair at 60% left 146px of room for a\n\t// card that wants up to 260, and `frameStyle`'s overflow:hidden took the rest off.\n\tconst flip = x + HOVER_GUTTER + HOVER_CARD_MAX > paneWidth\n\treturn (\n\t\t<div\n\t\t\tstyle={{\n\t\t\t\t...hoverCard,\n\t\t\t\tleft: flip ? undefined : x + HOVER_GUTTER,\n\t\t\t\tright: flip ? paneWidth - x + HOVER_GUTTER : undefined,\n\t\t\t}}\n\t\t>\n\t\t\t<div style={hoverDate}>{date}</div>\n\t\t\t{rows.map((row) => (\n\t\t\t\t<div key={row.key} style={hoverRow}>\n\t\t\t\t\t{/* The same block that labels the row in the plot, so the two are one legend. */}\n\t\t\t\t\t<span style={{ ...hoverSwatch, background: row.color }} />\n\t\t\t\t\t<span style={hoverLabel}>{row.label}</span>\n\t\t\t\t\t<span style={hoverValue}>\n\t\t\t\t\t\t{row.value}\n\t\t\t\t\t\t{row.seen && <span style={hoverSeen}> · {row.seen}</span>}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t))}\n\t\t\t{events.map((event) => (\n\t\t\t\t<div key={event} style={hoverEvent}>{event}</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n\n/**\n * The floating card.\n *\n * Its ground is the Studio's own card colour so it reads as a surface rather than a tooltip drawn\n * by the chart, and it carries a border because at low contrast a shadow alone disappears on the\n * dark theme.\n */\nconst hoverCard: React.CSSProperties = {\n\tposition: 'absolute',\n\ttop: 8,\n\t// Never the element under the pointer: the card follows the crosshair, and taking a pointer\n\t// event would end the hover that positions it.\n\tpointerEvents: 'none',\n\tzIndex: 2,\n\tminWidth: 168,\n\tmaxWidth: HOVER_CARD_MAX,\n\tpadding: '8px 10px',\n\tborderRadius: 4,\n\tbackground: 'var(--card-bg-color, #ffffff)',\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.35))',\n\tboxShadow: '0 4px 14px rgba(0,0,0,0.18)',\n\tdisplay: 'flex',\n\tflexDirection: 'column',\n\tgap: 4,\n}\n\n/** The day, which is the card's heading. */\nconst hoverDate: React.CSSProperties = { fontSize: 11, fontWeight: 600, opacity: 0.9, marginBottom: 2 }\n\n/** One series line: swatch, name, value. */\nconst hoverRow: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, lineHeight: 1.4 }\n\n/** The colour block, matching the one beside the row's name in the plot. */\nconst hoverSwatch: React.CSSProperties = { width: 8, height: 8, borderRadius: 2, flex: '0 0 auto' }\n\n/** The series name, which yields its space to the figure. */\nconst hoverLabel: React.CSSProperties = { opacity: 0.75, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }\n\n/** The figure, pushed right and tabular so a column of them lines up. */\nconst hoverValue: React.CSSProperties = { marginLeft: 'auto', fontVariantNumeric: 'tabular-nums', fontWeight: 500, whiteSpace: 'nowrap' }\n\n/** What the lossier source saw of the same thing, quieter than the answer it qualifies. */\nconst hoverSeen: React.CSSProperties = { opacity: 0.6, fontWeight: 400 }\n\n/** A campaign send or other dated marker on the hovered day. */\nconst hoverEvent: React.CSSProperties = { fontSize: 11, opacity: 0.8, borderTop: '1px solid var(--card-border-color, rgba(128,128,128,0.25))', paddingTop: 4, marginTop: 2 }\n\n\nexport interface MetricFigureProps {\n\tmetric: MetricValue\n\t/** Accessible label describing what this number counts. */\n\tlabel: string\n\tsize?: number\n\t/**\n\t * What the number IS, which decides how it is written.\n\t *\n\t * MetricValue carries availability but not unit, so a percentage and a count arrive here\n\t * indistinguishable and both used to be written with the count formatter. The consent rate is a\n\t * 0-100 percentage: it rendered as a bare \"84\" beside \"GA4 sessions 357\" and \"Orders 7\", where\n\t * the obvious reading is 84 sessions out of 357 — a quarter — when the truth is 84%. A missing\n\t * suffix inverted the conclusion.\n\t *\n\t * 'percent' expects a 0-100 value, matching what measurementHealth produces.\n\t *\n\t * 'money' writes the figure in the range's currency. Without it a PARTIAL revenue reached the\n\t * count formatter, so Darden's default tab — where revenue is permanently partial, because most\n\t * orders predate the amount field — rendered its headline money as a bare \"12,346\", beside an\n\t * \"Average order US$455\" that did carry a currency.\n\t */\n\tunit?: 'count' | 'percent' | 'money'\n\t/** ISO 4217 code. Read only when `unit` is 'money'. */\n\tcurrency?: string | null\n}\n\n/**\n * The badge on a partial figure.\n *\n * It was the fixed string \"Some orders only\", written for the revenue case and then applied to\n * every partial metric in the tool — including the GA4 view counts on Typeface interest, where it\n * is simply false: those are pageviews spanning an event cutover, and no order is involved.\n *\n * `coveredFrom` distinguishes the two. A cutover or an outage carries the date coverage begins; a\n * gap in the order book does not.\n *\n * @param metric - the partial value\n */\nfunction partialBadge(metric: { coveredFrom?: string }): string {\n\treturn metric.coveredFrom ? `From ${formatDay(metric.coveredFrom)} only` : 'Some orders only'\n}\n\n/**\n * Render a metric value, handling the absent case visibly.\n * Screen readers get the reason text rather than an unexplained dash.\n */\nexport function MetricFigure({ metric, label, size = 4, unit = 'count', currency = null }: MetricFigureProps): React.ReactElement {\n\tif (metric.status === 'unavailable') {\n\t\t// Guarded: the route can be newer than the Studio and send a reason this build has never\n\t\t// heard of, which rendered the word \"undefined\" to the reader.\n\t\tconst reason = REASON_TEXT[metric.reason] ?? 'No figure available'\n\t\tconst detail = metric.detail ? `${reason}. ${metric.detail}` : reason\n\n\t\treturn (\n\t\t\t// The reason travels as REAL TEXT in a visually-hidden span, not as an aria-label and not\n\t\t\t// only in a tooltip.\n\t\t\t//\n\t\t\t// `aria-label` on a <Text> is silently dropped: it renders a bare <div>, whose role is\n\t\t\t// `generic`, on which aria-label is prohibited by ARIA and ignored by every major\n\t\t\t// browser. The em dash beside it is aria-hidden. So an unavailable metric announced as\n\t\t\t// an EMPTY NODE — a blank cell — which is exactly the \"absent must never look like a\n\t\t\t// measured zero\" invariant this whole type exists to enforce, inverted for anyone not\n\t\t\t// looking at the screen. The tooltip was the only other route and it hangs off a\n\t\t\t// non-focusable element, so it was pointer-only too.\n\t\t\t//\n\t\t\t// Delta already does this correctly; this is that pattern applied.\n\t\t\t<Tooltip content={<Box padding={2}><Text size={1}>{detail}</Text></Box>} portal>\n\t\t\t\t<Text size={size} muted>\n\t\t\t\t\t<span aria-hidden=\"true\">—</span>\n\t\t\t\t\t<span style={visuallyHidden}>{label}: unavailable. {detail}</span>\n\t\t\t\t</Text>\n\t\t\t</Tooltip>\n\t\t)\n\t}\n\n\t// Percentages keep one decimal, since that is the precision the server produced; rounding to a\n\t// whole number here would make a 0.4-point move look like no move at all.\n\tconst formatted = unit === 'percent'\n\t\t? `${metric.value.toFixed(1)}%`\n\t\t: unit === 'money'\n\t\t\t? formatMoney(metric.value, currency)\n\t\t\t: formatCount(metric.value)\n\n\tif (metric.status === 'partial') {\n\t\treturn (\n\t\t\t<Stack space={2}>\n\t\t\t\t<Text size={size}>\n\t\t\t\t\t{formatted}\n\t\t\t\t\t<span style={visuallyHidden}>, partial. {metric.note}</span>\n\t\t\t\t</Text>\n\t\t\t\t<Badge tone=\"caution\" fontSize={0}>{partialBadge(metric)}</Badge>\n\t\t\t</Stack>\n\t\t)\n\t}\n\n\tif (metric.status === 'estimated') {\n\t\t// Marked as inferred at every level: a tilde on the number, the interval beneath, a badge,\n\t\t// and the basis in the accessible name. Adding the variant to the union was not enough on\n\t\t// its own — this branch did not exist at first, so an estimate fell through to the plain\n\t\t// return below and rendered as a measured figure, which is the one thing the variant was\n\t\t// introduced to make impossible.\n\t\tconst range = unit === 'percent'\n\t\t\t? `${metric.low.toFixed(1)}% to ${metric.high.toFixed(1)}%`\n\t\t\t: unit === 'money'\n\t\t\t\t? `${formatMoney(metric.low, currency)} to ${formatMoney(metric.high, currency)}`\n\t\t\t\t: `${formatCount(metric.low)} to ${formatCount(metric.high)}`\n\n\t\treturn (\n\t\t\t<Stack space={2}>\n\t\t\t\t<Text size={size}>\n\t\t\t\t\t<span aria-hidden=\"true\">~</span>{formatted}\n\t\t\t\t\t<span style={visuallyHidden}>estimated, between {range}. {metric.basis}</span>\n\t\t\t\t</Text>\n\t\t\t\t<Text size={0} muted>{range}</Text>\n\t\t\t\t<Badge tone=\"primary\" fontSize={0}>Estimated</Badge>\n\t\t\t</Stack>\n\t\t)\n\t}\n\n\tif (metric.status !== 'ok') {\n\t\t// Exhaustiveness guard. `estimated` was added to the union and rendered as a measurement\n\t\t// for exactly as long as it took to notice; this makes the compiler refuse the next one.\n\t\tconst unhandled: never = metric\n\t\tvoid unhandled\n\t}\n\n\treturn <Text size={size}>{formatted}</Text>\n}\n\n/** Props for ChartData. */\nexport interface ChartDataProps<Row> {\n\t/** What the disclosure is called. Names the chart it belongs to. */\n\tlabel: string\n\trows: Row[]\n\tcolumns: Array<SortColumn<Row>>\n\trowKey: (row: Row) => string\n\texportName?: string\n\t/** A line above the table, for anything a column header cannot carry. */\n\tnote?: string\n\t/** Which column to filter on, if the table should offer a filter box. */\n\tfilterOn?: (row: Row) => string\n}\n\n/**\n * The figures behind a chart, as a table, collapsed by default.\n *\n * A chart is a picture of the data and not the data. Every other panel here renders a table for\n * exactly that reason — the file header used to say so, and it stopped being true the moment the\n * charts landed, leaving two time series with no equivalent anywhere in the tool.\n *\n * One disclosure serves four readers at once: someone using a screen reader, for whom an SVG under\n * `role=\"img\"` is a single opaque node; someone navigating by keyboard; someone who cannot resolve\n * axis type at any size; and anyone who wants these numbers in a spreadsheet, since the table\n * brings sorting and CSV copy with it. Collapsed, so it costs a sighted reader one line.\n */\nexport function ChartData<Row>({ label, rows, columns, rowKey, exportName, note, filterOn }: ChartDataProps<Row>): React.ReactElement | null {\n\tif (rows.length === 0) return null\n\n\treturn (\n\t\t<details style={disclosureBlock}>\n\t\t\t<summary style={disclosureSummary}>{label}</summary>\n\t\t\t<div style={{ marginTop: 10 }}>\n\t\t\t\t{note && <Text size={0} muted style={{ marginBottom: 8, display: 'block' }}>{note}</Text>}\n\t\t\t\t<SortableTable<Row>\n\t\t\t\t\tcaption={label}\n\t\t\t\t\trows={rows}\n\t\t\t\t\tcolumns={columns}\n\t\t\t\t\trowKey={rowKey}\n\t\t\t\t\texportName={exportName}\n\t\t\t\t\tfilterOn={filterOn}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</details>\n\t)\n}\n\n/** The disclosure wrapper for a chart's figures. */\nconst disclosureBlock: React.CSSProperties = { width: '100%' }\n\n/** Its control. Underlined so it reads as actionable, like the caveat disclosure. */\nconst disclosureSummary: React.CSSProperties = {\n\tcursor: 'pointer',\n\tfont: 'inherit',\n\tfontSize: '0.85em',\n\topacity: 0.75,\n\ttextDecoration: 'underline',\n\ttextUnderlineOffset: 3,\n\twidth: 'fit-content',\n}\n\n/** One bar of a proportion chart. */\nexport interface ProportionBar {\n\tkey: string\n\tlabel: string\n\tsublabel?: string\n\tvalue: number\n}\n\n/** Props for ProportionChart. */\nexport interface ProportionChartProps {\n\tbars: ProportionBar[]\n\t/** How to write each value out. */\n\tformat: (value: number) => string\n\t/** What the bars sum to, named — a share is meaningless without its denominator stated. */\n\ttotalLabel: string\n}\n\n/**\n * A ranked part-to-whole bar chart.\n *\n * For a breakdown whose rows sum to something meaningful — licence revenue by tier, say. Each bar\n * carries its share of the total AND its absolute value, because a share alone hides that the\n * leading row might be two orders, and an absolute alone hides that it is most of the business.\n *\n * Bars scale against the SUM rather than against the largest row, so the widths read as shares of\n * the whole. Scaling to the max would make the top row full-width whatever it was worth, which is\n * the same misreading the funnel avoids by anchoring to its entry step.\n */\nexport function ProportionChart({ bars, format, totalLabel }: ProportionChartProps): React.ReactElement | null {\n\tif (bars.length === 0) return null\n\n\tconst total = bars.reduce((sum, bar) => sum + bar.value, 0)\n\tif (total <= 0) return null\n\n\tconst ordered = [...bars].sort((a, b) => b.value - a.value)\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t{ordered.map((bar) => {\n\t\t\t\tconst share = bar.value / total\n\t\t\t\treturn (\n\t\t\t\t\t<Stack space={1} key={bar.key}>\n\t\t\t\t\t\t<div style={barHeader}>\n\t\t\t\t\t\t\t<Text size={1} weight=\"medium\">\n\t\t\t\t\t\t\t\t{bar.label}\n\t\t\t\t\t\t\t\t{bar.sublabel && <Text size={0} muted as=\"span\">{' '}· {bar.sublabel}</Text>}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\t{format(bar.value)}\n\t\t\t\t\t\t\t\t<Text size={0} muted as=\"span\">{' '}({formatPercent(share, 0)})</Text>\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div aria-hidden=\"true\" style={barTrack}>\n\t\t\t\t\t\t\t<div style={{ ...barFill, width: `${Math.max(1, share * 100)}%` }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Stack>\n\t\t\t\t)\n\t\t\t})}\n\t\t\t<Text size={0} muted>{totalLabel}: {format(total)}</Text>\n\t\t</Stack>\n\t)\n}\n\n/** One rung of the funnel, already filtered to steps that are actually measured. */\nexport interface FunnelStage {\n\tkey: string\n\tlabel: string\n\t/** Distinct users at this step. */\n\tvalue: number\n\t/** Share of the previous shown step, or null when it could not be computed. */\n\tconversionFromPrevious: number | null\n\t/**\n\t * Set when this step was only instrumented part-way through the window.\n\t *\n\t * The count is real but it covers fewer days than the rungs around it, so any ratio drawn\n\t * across the two is arithmetic over mismatched windows. `JourneyPanel` used to drop the\n\t * metric's `partial` status on the way in, so a `view_item` that began firing on 1 September\n\t * was drawn identically to a full-month `page_view` and captioned \"33.9% of landed\" — a\n\t * thirteen-day numerator over a thirty-one-day denominator, stated to a decimal place.\n\t *\n\t * Everywhere else a partial metric carries a badge and a note; this was the one render site\n\t * that stripped it, and it is the most quoted number on the tab.\n\t */\n\tpartial?: { from: string }\n\t/**\n\t * This rung split by audience segment, in a stable order across rungs.\n\t *\n\t * The bar's WIDTH is unchanged — still this rung's share of the entry step — and the split\n\t * subdivides that width. So a segment collapsing reads as its portion shrinking down the column,\n\t * against a total that stays honestly anchored to entry.\n\t *\n\t * This replaces a second chart. `SurvivalLines` drew the same segments as normalised shares on\n\t * its own axis, above a funnel drawing the pooled counts: two pictures of one fact, and the\n\t * share-only one could not print a count, which at rungs of 23 and 12 and 7 is the only fact\n\t * that survives. Its denominator floor also silenced it at exactly those rungs.\n\t */\n\tsegments?: ReadonlyArray<{ key: string; label: string; value: number }>\n}\n\n/** Props for FunnelChart. */\nexport interface FunnelChartProps {\n\tstages: FunnelStage[]\n\t/**\n\t * Whether the stages are a tracked sequence or independent totals. The drawing is the same\n\t * shape either way, but the words between stages are not: a fallback's gap is a difference\n\t * between two counts, not people who dropped out.\n\t */\n\tmeasurement: 'sequence' | 'independent-totals'\n}\n\n/**\n * The smallest denominator a step-to-step rate may be computed on.\n *\n * Thirty. Below it one extra visitor moves the printed percentage by more than three points, so the\n * figure describes the arrival of a single person rather than the behaviour of a group — and at\n * these foundries' volumes the lower rungs of a funnel sit well under it. The count beside it is\n * still shown, because how many people reached a step is a fact at any size; it is the ratio that\n * needs a population.\n */\nexport const MIN_RATE_DENOMINATOR = 30\n\n/**\n * An ISO date as a reader would say it: `1 Sep`.\n *\n * The cutover note states the raw `2026-09-01`, which is right in a machine-written caveat and\n * wrong in a sentence under a chart.\n *\n * @param iso - a `YYYY-MM-DD` date\n */\nexport function formatDay(iso: string): string {\n\tconst date = new Date(`${iso}T00:00:00Z`)\n\tif (Number.isNaN(date.getTime())) return iso\n\treturn `${date.getUTCDate()} ${date.toLocaleString('en-GB', { month: 'short', timeZone: 'UTC' })}`\n}\n\n/**\n * The colour for one audience segment.\n *\n * Colour, not pattern. The chart this came from separated its lines by dash alone, which ran out at\n * three segments and was distorted by the stretched viewBox it was drawn in. The keys are borrowed\n * from the series palette because they already clear 3:1 on both grounds; they carry no source\n * meaning here, and the key beneath the funnel states which is which.\n *\n * @param index - the segment's position in the stack\n */\nfunction segmentColour(index: number): string {\n\tconst names = ['survival.line', 'survival.line.2', 'survival.line.3', 'survival.line.4']\n\treturn mark(names[index % names.length] as string)\n}\n\n/**\n * The segments present anywhere in the funnel, in first-seen order.\n *\n * Read off the stages rather than passed in, so the key cannot name a segment no rung draws — the\n * failure this package keeps finding in its own legends.\n *\n * @param stages - the rungs\n */\nfunction segmentKeys(stages: readonly FunnelStage[]): Array<{ key: string; label: string }> {\n\tconst seen = new Map<string, string>()\n\tfor (const stage of stages) {\n\t\tfor (const segment of stage.segments ?? []) {\n\t\t\tif (segment.value > 0 && !seen.has(segment.key)) seen.set(segment.key, segment.label)\n\t\t}\n\t}\n\treturn [...seen].map(([key, label]) => ({ key, label }))\n}\n\n/**\n * One segment's share of the rung it sits in.\n *\n * Clamped at the bottom only. A segment larger than the rung it belongs to is a real disagreement\n * between two GA4 queries rather than a rendering problem, and letting the row overflow its track\n * would hide it — so the shares are normalised by their own sum when they exceed the whole, which\n * keeps the bar's length truthful and lets the imbalance show as a changed proportion.\n *\n * @param value - the segment's count\n * @param whole - the rung's count\n */\nfunction segmentShare(value: number, whole: number): number {\n\tif (!(whole > 0) || !(value > 0)) return 0\n\treturn Math.min(1, value / whole)\n}\n\n/**\n * The line under one funnel rung.\n *\n * ONE clause, where there used to be two. The step-to-step rate was a second sentence on the same\n * line, each half with its own withhold-fallback — so on a six-rung funnel at a foundry's volumes\n * the panel printed \"too few to give a rate\" six times, twice per rung on three consecutive rungs.\n * It was the most repeated string in the tool, and the two clauses were the same picture twice:\n * the bars are anchored to entry, so share-of-entry is already drawn.\n *\n * Silence is the honest default when a rate is withheld — the count is still in the bar header, and\n * `shortListNote` elsewhere in this package already follows that rule. The one thing worth saying\n * out loud is a partial window, because that is a fact about the number rather than an absence of\n * one.\n *\n * @param stage - the rung\n * @param index - its position, so the entry rung can name the scale\n * @param entry - the first rung's count, which is the denominator and the rail's full width\n * @param share - stage over entry, unclamped\n * @param entryLabel - the first rung's name, for the sentence\n */\nfunction rateLine(\n\tstage: FunnelStage,\n\tindex: number,\n\tentry: number,\n\tshare: number,\n\tentryLabel: string,\n): React.ReactElement | null {\n\t// Turns the rail into a scale. \"entry step\" restated that the first bar was the first bar; this\n\t// says what a full-width bar means, which is the one thing the chart never stated.\n\tif (index === 0) return <Text size={0} muted>100% — everyone who arrived</Text>\n\n\t// A count measured over fewer days than its denominator cannot carry a rate against it.\n\t//\n\t// The wording matters as much as the withholding. \"Only counted from 1 Sep\" reads as a fault —\n\t// something broke, GA4 lost data — when the truth is the opposite: nobody was looking before\n\t// that date because the tracking did not exist. The server already builds that sentence and it\n\t// was being dropped on the way in.\n\tif (stage.partial) {\n\t\treturn (\n\t\t\t<Text size={0} muted>\n\t\t\t\ttracking added {stage.partial.from}, so this step covers fewer days than the ones above — no\n\t\t\t\tshare is shown\n\t\t\t</Text>\n\t\t)\n\t}\n\n\tconst canRate = entry >= MIN_RATE_DENOMINATOR && stage.value >= MIN_RATE_DENOMINATOR\n\tif (!canRate) return null\n\n\treturn <Text size={0} muted>{formatPercent(share, 1)} of {entryLabel.toLowerCase()}</Text>\n}\n\n/**\n * A funnel.\n *\n * Widths are a share of the FIRST stage rather than of the largest, which is what makes it read as\n * a funnel: every rung answers \"of everyone who arrived, how many got this far\". Anchoring to the\n * max instead would make the widest stage full-width wherever it sat, and a mid-funnel step wider\n * than entry — which happens on the independent-totals fallback, where `add_to_cart` can exceed\n * `page_view` — would silently rescale everything above it.\n *\n * Stages are focusable so the figures are reachable without a pointer; the readout is rendered as\n * text under the stage rather than as a floating tooltip so it is also visible on touch.\n */\nexport function FunnelChart({ stages, measurement }: FunnelChartProps): React.ReactElement | null {\n\tconst [activeKey, setActiveKey] = useState<string | null>(null)\n\tconst refs = useRef<Array<HTMLDivElement | null>>([])\n\n\tconst entry = stages[0]?.value ?? 0\n\tif (stages.length === 0) return null\n\n\tconst onKeyDown = (event: React.KeyboardEvent, index: number) => {\n\t\tlet next: number | null = null\n\t\tif (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = Math.min(index + 1, stages.length - 1)\n\t\tif (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = Math.max(index - 1, 0)\n\t\tif (event.key === 'Home') next = 0\n\t\tif (event.key === 'End') next = stages.length - 1\n\t\tif (next === null) return\n\t\tevent.preventDefault()\n\t\trefs.current[next]?.focus()\n\t}\n\n\t// Whether ANY rung had to withhold a rate. One sentence under the chart is the honest scope for\n\t// this: the rule is a property of the funnel, not of each rung, and stating it per rung made it\n\t// the most repeated string in the tool.\n\tconst withheldAny = stages.some((stage, index) =>\n\t\tindex > 0 && !stage.partial && !(entry >= MIN_RATE_DENOMINATOR && stage.value >= MIN_RATE_DENOMINATOR),\n\t)\n\n\treturn (\n\t\t<>\n\t\t<ol style={funnelList}>\n\t\t\t{stages.map((stage, index) => {\n\t\t\t\tconst previous = index > 0 ? stages[index - 1] : null\n\t\t\t\t// The share is printed unclamped and the WIDTH is clamped separately. Clamping the\n\t\t\t\t// share itself printed a fallback stage of 500 against an entry of 100 as \"100.0%\n\t\t\t\t// of landed\" — the one reading that hides the anomaly the caveat exists to explain.\n\t\t\t\tconst share = entry > 0 ? stage.value / entry : 0\n\t\t\t\tconst width = Math.max(1.5, Math.min(1, share) * 100)\n\t\t\t\tconst active = activeKey === stage.key\n\t\t\t\t// Whether this rung is split, and how much of it the named segments leave over.\n\t\t\t\tconst hasSplit = (stage.segments?.length ?? 0) > 0\n\t\t\t\tconst remainder = hasSplit\n\t\t\t\t\t? Math.max(0, 1 - (stage.segments ?? []).reduce((sum, s) => sum + segmentShare(s.value, stage.value), 0))\n\t\t\t\t\t: 0\n\t\t\t\tconst delta = previous ? previous.value - stage.value : 0\n\n\t\t\t\treturn (\n\t\t\t\t\t<li key={stage.key} style={funnelItem}>\n\t\t\t\t\t\t{previous && gapLabel(delta, measurement, entry) !== '' && (\n\t\t\t\t\t\t\t<div style={funnelGapStyle(measurement === 'sequence' && delta > 0 && entry > 0)}>\n\t\t\t\t\t\t\t\t{/* Drawn on the SAME RAIL as the stage bars, and that is the whole point.\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t This previously had a rail of its own at `width: 38%` with the fill a\n\t\t\t\t\t\t\t\t percentage OF THAT RAIL — so a drop-off of half your entrants rendered\n\t\t\t\t\t\t\t\t at 19% of the width a 50% stage bar occupies, understated by a factor\n\t\t\t\t\t\t\t\t of 2.6, beneath a comment claiming the two shared a scale. The test\n\t\t\t\t\t\t\t\t asserted `width: 75%` for a 750-of-1000 loss and passed, because the\n\t\t\t\t\t\t\t\t fill really was 75% of its own rail: it checked the arithmetic and\n\t\t\t\t\t\t\t\t never the relationship.\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t Full width, right-aligned, same 100% as every rung above and below. */}\n\t\t\t\t\t\t\t\t{measurement === 'sequence' && delta > 0 && entry > 0 && (\n\t\t\t\t\t\t\t\t\t<div aria-hidden=\"true\" style={{ ...barTrack, height: 6, flex: 1 }}>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tmarginLeft: 'auto',\n\t\t\t\t\t\t\t\t\t\t\t\theight: '100%',\n\t\t\t\t\t\t\t\t\t\t\t\tborderRadius: 2,\n\t\t\t\t\t\t\t\t\t\t\t\tbackground: mark('bar.lost'),\n\t\t\t\t\t\t\t\t\t\t\t\twidth: `${Math.max(1, Math.min(1, delta / entry) * 100)}%`,\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{/* NOT inside the aria-hidden. The whole gap block used to be hidden, so a\n\t\t\t\t\t\t\t\t screen-reader user got five stage counts and nothing about drop-off at\n\t\t\t\t\t\t\t\t all — including the warning that the gaps are not drop-offs under the\n\t\t\t\t\t\t\t\t fallback. Only the bar is decoration; the sentence is the finding. */}\n\t\t\t\t\t\t\t\t<Text size={0} muted>{gapLabel(delta, measurement, entry)}</Text>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tref={(el: HTMLDivElement | null) => {\n\t\t\t\t\t\t\t\trefs.current[index] = el\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\trole=\"listitem\"\n\t\t\t\t\t\t\tstyle={funnelStage(active)}\n\t\t\t\t\t\t\tonMouseEnter={() => setActiveKey(stage.key)}\n\t\t\t\t\t\t\tonMouseLeave={() => setActiveKey(null)}\n\t\t\t\t\t\t\tonFocus={() => setActiveKey(stage.key)}\n\t\t\t\t\t\t\tonBlur={() => setActiveKey(null)}\n\t\t\t\t\t\t\tonKeyDown={(e) => onKeyDown(e, index)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div style={barHeader}>\n\t\t\t\t\t\t\t\t<Text size={1} weight=\"medium\">{stage.label}</Text>\n\t\t\t\t\t\t\t\t<Text size={1} weight=\"semibold\">{formatCount(stage.value)}</Text>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{/* A partial rung gets NO BAR AT ALL — not a short one, and not a long one.\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t Drawing it at its raw share was the first mistake: the sentence said \"no share\n\t\t\t\t\t\t\t is shown\" while the geometry went on showing one, about half the length it\n\t\t\t\t\t\t\t should be. Drawing it as a full-width hatched rail was the second, and it was\n\t\t\t\t\t\t\t worse: in a column where LENGTH IS THE VALUE, that handed the least-measured\n\t\t\t\t\t\t\t rung the longest mark on the chart — a refusal drawn with more authority than\n\t\t\t\t\t\t\t any measurement on the page.\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t An empty slot in a column of bars cannot be mis-measured in either direction,\n\t\t\t\t\t\t\t and the count in the header above keeps it from reading as a zero. */}\n\t\t\t\t\t\t\t{stage.partial\n\t\t\t\t\t\t\t\t? <div style={partialSlot} />\n\t\t\t\t\t\t\t\t: (\n\t\t\t\t\t\t\t\t\t<div aria-hidden=\"true\" style={{ ...barTrack, height: 10 }}>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t...barFill,\n\t\t\t\t\t\t\t\t\t\t\t\twidth: `${width}%`,\n\t\t\t\t\t\t\t\t\t\t\t\tdisplay: 'flex',\n\t\t\t\t\t\t\t\t\t\t\t\toverflow: 'hidden',\n\t\t\t\t\t\t\t\t\t\t\t\t// Transparent when split. Left as the ordinary fill, the parent's own\n\t\t\t\t\t\t\t\t\t\t\t\t// colour showed through wherever the segments did not reach — and on a\n\t\t\t\t\t\t\t\t\t\t\t\t// rung where they sum to 451 of 475 that painted the missing 24 in the\n\t\t\t\t\t\t\t\t\t\t\t\t// FIRST segment's colour, reading as a third segment of it.\n\t\t\t\t\t\t\t\t\t\t\t\t...(hasSplit ? { background: 'transparent' } : {}),\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{/* The split subdivides the bar; it never changes its length. The rung's\n\t\t\t\t\t\t\t\t\t\t\t width stays its share of entry, so the funnel's scale is untouched and\n\t\t\t\t\t\t\t\t\t\t\t the segments read as portions of a total that is still anchored. */}\n\t\t\t\t\t\t\t\t\t\t\t{(stage.segments ?? []).map((segment, segmentIndex) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={segment.key}\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\twidth: `${segmentShare(segment.value, stage.value) * 100}%`,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackground: segmentColour(segmentIndex),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\theight: '100%',\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t{/* Whatever the named segments do not account for — a device category GA4\n\t\t\t\t\t\t\t\t\t\t\t reported under neither, or a rounding gap between two queries. Drawn in\n\t\t\t\t\t\t\t\t\t\t\t the neutral so it cannot be mistaken for a segment, and keyed as such. */}\n\t\t\t\t\t\t\t\t\t\t\t{hasSplit && remainder > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div style={{ width: `${remainder * 100}%`, background: mark('bar.track'), height: '100%' }} />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{/* Both ratios where they differ — the panel used to print only the\n\t\t\t\t\t\t\t step-to-step one, and a reader comparing two adjacent small\n\t\t\t\t\t\t\t percentages had no way to see how narrow the funnel had already\n\t\t\t\t\t\t\t become. On stage two the previous step IS entry, so printing both\n\t\t\t\t\t\t\t read as \"100.0% of landed · 500.0% of landed\". */}\n\t\t\t\t\t\t\t{/*\n\t\t\t\t\t\t\t * A rate is printed only where its denominator can carry one.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * This was the last figure in the tool still stating a small-sample number\n\t\t\t\t\t\t\t * with full authority. At seven orders a quarter and a fifth of traffic\n\t\t\t\t\t\t\t * captured, the lower rungs hold single digits — so \"33.3% of began\n\t\t\t\t\t\t\t * checkout\" was one visitor out of three, drawn to a decimal place and\n\t\t\t\t\t\t\t * sitting in the same type as a rate computed on hundreds. One more sale\n\t\t\t\t\t\t\t * moves it thirty points.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * The count still shows: how many people reached a step is a fact at any\n\t\t\t\t\t\t\t * size. It is the RATIO that needs a denominator, which is the same rule\n\t\t\t\t\t\t\t * the revenue split and the catalogue index already follow.\n\t\t\t\t\t\t\t */}\n\t\t\t\t\t\t\t{rateLine(stage, index, entry, share, stages[0]?.label ?? '')}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</li>\n\t\t\t\t)\n\t\t\t})}\n\t\t</ol>\n\t\t{/* The hatch gets a key. It is a new mark, drawn on the loudest rungs in the chart, and it\n\t\t shipped with nothing anywhere saying what it meant — in the same work whose whole subject\n\t\t was legends that do not match their graphs. */}\n\t\t{segmentKeys(stages).length > 0 && (\n\t\t\t<div style={funnelSegmentKey}>\n\t\t\t\t{segmentKeys(stages).map((segment, index) => (\n\t\t\t\t\t<Text key={segment.key} size={0} muted>\n\t\t\t\t\t\t<span aria-hidden=\"true\" style={{ ...hoverSwatch, background: segmentColour(index), display: 'inline-block', marginRight: 6 }} />\n\t\t\t\t\t\t{segment.label}\n\t\t\t\t\t</Text>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t)}\n\t\t{stages.some((stage) => stage.partial) && (\n\t\t\t<Text size={0} muted>\n\t\t\t\t{/* Explains the ABSENCE, now that there is no mark to key. A legend for a stripe that is\n\t\t\t\t no longer drawn would be the fault this work started from, in reverse. */}\n\t\t\t\tSteps with no bar were only tracked for part of this period, so they cover fewer days than\n\t\t\t\tthe ones above and cannot be drawn against them. Their counts are exact for the days they\n\t\t\t\tdo cover.\n\t\t\t</Text>\n\t\t)}\n\t\t{withheldAny && (\n\t\t\t<Text size={0} muted>\n\t\t\t\tShares are shown only for steps at least {MIN_RATE_DENOMINATOR} people reached. Below that a\n\t\t\t\tpercentage moves too far on one more visitor to mean anything; the counts above are exact.\n\t\t\t</Text>\n\t\t)}\n\t\t</>\n\t)\n}\n\n/**\n * The words in the gap between two rungs.\n *\n * A tracked funnel loses people; independent totals merely differ, and a later total can exceed an\n * earlier one — `add_to_cart` fires per selection on two of the three sites, so the cart step can\n * sit above `page_view`. That case must not print as \"no difference\", which is what a\n * greater-than-zero test alone produced.\n *\n * @param delta - previous step's value minus this one's; negative means this step is larger\n */\nfunction gapLabel(delta: number, measurement: 'sequence' | 'independent-totals', entry = 0): string {\n\t// On the fallback, only the ANOMALY is worth a line.\n\t//\n\t// The drop-off BAR is drawn for a tracked sequence only, so on independent totals every gap\n\t// label rendered right-aligned against empty space. \"N fewer\" was also arithmetic the reader can\n\t// do from the two counts either side of it, on every gap, under a caution card that specifically\n\t// forbids reading these gaps as drop-off — so it was noise that contradicted the warning above\n\t// it. A rung LARGER than the one above cannot be inferred that way and is the fallback's\n\t// characteristic surprise, so that one stays.\n\tif (measurement !== 'sequence' && delta >= 0) return ''\n\tif (delta === 0) return 'no drop-off'\n\tif (delta > 0) {\n\t\t// The share as well as the count, because the share is what the bar beside it encodes — and\n\t\t// a screen-reader user, who gets no bar at all, would otherwise have no way to know how big\n\t\t// the loss was relative to everyone who landed.\n\t\tconst share = entry > 0 ? ` — ${formatPercent(delta / entry, 0)} of everyone who landed` : ''\n\t\treturn `−${formatCount(delta)} did not continue${share}`\n\t}\n\t// A rung larger than the one above it. Under independent totals that is the fallback's\n\t// characteristic surprise — add_to_cart can exceed page_view — and the guard above routes it\n\t// here deliberately. Under a tracked sequence it should be impossible, which makes it worth\n\t// stating either way.\n\treturn `${formatCount(-delta)} more — not a subset of the step above`\n}\n\n/**\n * The space a bar would have occupied on a rung that has none.\n *\n * Reserved rather than collapsed, so the rungs keep their rhythm down the column and the missing\n * mark reads as deliberate rather than as something that failed to render.\n */\nconst partialSlot: React.CSSProperties = { height: 10 }\n\n/** The segment key, sat under the funnel with the other things said once per chart. */\nconst funnelSegmentKey: React.CSSProperties = { display: 'flex', gap: SPACE.group, flexWrap: 'wrap', paddingTop: SPACE.pair }\n\n/** One rung: tier, term, bar, figures. */\nconst ladderRow: React.CSSProperties = {\n\tdisplay: 'grid',\n\tgridTemplateColumns: 'minmax(80px, 1.1fr) minmax(60px, 0.8fr) minmax(60px, 2fr) auto',\n\tgap: SPACE.block,\n\talignItems: 'center',\n}\n\n/** The rail a rung's bar sits in. */\nconst ladderTrack: React.CSSProperties = {\n\tposition: 'relative',\n\theight: 8,\n\tborderRadius: 4,\n\toverflow: 'hidden',\n\tbackground: mark('bar.track'),\n}\n\n/** Orders and money, right-aligned and tabular so a column of them lines up. */\nconst ladderValue: React.CSSProperties = { textAlign: 'right', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }\n\n/** The apportioned share, quieter than the exact count it follows. */\nconst ladderMoney: React.CSSProperties = { opacity: 0.7 }\n\n/** The funnel's list wrapper. Numbering is suppressed — the rungs are already in order visually. */\nconst funnelList: React.CSSProperties = { listStyle: 'none', margin: 0, padding: 0 }\n\n/** One rung and its preceding gap. */\nconst funnelItem: React.CSSProperties = { display: 'grid', gap: 4 }\n\n/**\n * The space between two rungs, where the drop-off is named.\n *\n * Right-aligned only when there is a bar to align against. Under independent totals no bar is\n * drawn, so `flex-end` pushed the text to the right of nothing.\n */\nfunction funnelGapStyle(hasBar: boolean): React.CSSProperties {\n\treturn {\n\t\tdisplay: 'flex',\n\t\talignItems: 'center',\n\t\tjustifyContent: hasBar ? 'flex-end' : 'flex-start',\n\t\tgap: 8,\n\t\tpadding: '4px 2px',\n\t}\n}\n\n\n\n/** One rung. The active state is a background and a border, so it survives a forced-colours mode. */\nfunction funnelStage(active: boolean): React.CSSProperties {\n\treturn {\n\t\tdisplay: 'grid',\n\t\tgap: 6,\n\t\tpadding: '8px 10px',\n\t\tborderRadius: 3,\n\t\tborder: `1px solid ${active ? 'currentColor' : 'transparent'}`,\n\t\tbackground: active ? 'var(--card-bg-color, rgba(128,128,128,0.08))' : 'transparent',\n\t\tcursor: 'default',\n\t}\n}\n\n\n/**\n * The fill for a bar's measured portion.\n *\n * An explicit background, NOT `<Card tone=\"primary\">`. Sanity's card tones are page backgrounds\n * meant to sit behind text, never accents: measured against the surrounding track they come out at\n * 1.03:1 in light and 1.12:1 in dark, where WCAG's floor for a meaningful graphical object is 3:1.\n * Three of the four bar idioms were rendering as empty rails with numbers beside them, and\n * `tone=\"default\"` was worse still — a bar lighter than its own track.\n *\n * It also survives the compat shim, whose DOM fallback drops `tone`, `padding`, `radius` and\n * `border` entirely but passes `style` through. A bar whose existence depends on a resolved\n * design token is a bar that vanishes on the Studio versions the shim exists for.\n */\nconst barFill: React.CSSProperties = {\n\theight: '100%',\n\tborderRadius: 2,\n\t// Through the registry, not a hex plus an opacity chosen here. Drawn at 0.85 this sat at 2.77:1\n\t// on the light theme while palette.test.ts asserted 3.42:1 against the bare constant — the test\n\t// measured a colour that was never drawn. MARKS is what both now read.\n\tbackground: mark('bar.fill'),\n}\n\n/** The track a bar sits in. Visible on its own, so an empty bar still reads as a bar. */\nconst barTrack: React.CSSProperties = {\n\theight: 8,\n\tborderRadius: 2,\n\toverflow: 'hidden',\n\t// Furniture, not data: a rail is not a value, so 3:1 does not apply to it — but it is declared\n\t// in MARKS so that exemption is stated somewhere a reader can check rather than assumed.\n\tbackground: mark('bar.track'),\n}\n\n\n\n/** Bar header: label on the left, figure hard right, never overlapping on a narrow pane. */\nconst barHeader: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'center',\n\tjustifyContent: 'space-between',\n\tgap: 12,\n\tflexWrap: 'wrap',\n}\n\n\n/** Disclosure control for the collapsed caveats. Underlined so it reads as actionable text. */\nconst disclosure: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: 'none',\n\tcolor: 'inherit',\n\topacity: 0.75,\n\tfont: 'inherit',\n\tfontSize: '0.85em',\n\tpadding: '2px 0',\n\ttextDecoration: 'underline',\n\ttextUnderlineOffset: 3,\n\tcursor: 'pointer',\n\tjustifySelf: 'start',\n\twidth: 'fit-content',\n}\n\n/** Notice row: badge and text side by side, wrapping on a narrow panel rather than colliding. */\nconst noticeRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: 12,\n\talignItems: 'flex-start',\n\tflexWrap: 'wrap',\n}\n\n/** The badge keeps its width; only the text reflows. */\nconst noticeBadge: React.CSSProperties = { flex: '0 0 auto' }\n\n/** Props for NoticeList. */\nexport interface NoticeListProps {\n\tnotices: string[]\n}\n\n/**\n * Caveats attached to a report — sampling, processing lag, instrumentation cutovers.\n * Rendered in the panel rather than a README: a caveat nobody sees does not prevent a wrong read.\n */\nexport function NoticeList({ notices }: NoticeListProps): React.ReactElement | null {\n\tconst [expanded, setExpanded] = React.useState(false)\n\n\tif (notices.length === 0) return null\n\n\t// Two is the most a reader will actually take in before the band becomes wallpaper. Beyond that\n\t// the rest collapse behind a count, because a stack of seven identical amber cards above the\n\t// data trains people to skip the one that mattered — and every caveat here is emitted on every\n\t// panel, relevant or not, so the stack is routinely long.\n\tconst alwaysShown = notices.slice(0, 2)\n\tconst hidden = notices.slice(2)\n\n\tconst item = (notice: string) => (\n\t\t<li key={notice}>\n\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t{/* Laid out with real CSS rather than the UI kit's Flex and its `gap` token. When\n\t\t\t\t the compat shim cannot resolve Flex it renders a plain div, and a token number\n\t\t\t\t means nothing to CSS — so the badge and the text landed on top of each other\n\t\t\t\t and the notice read \"Caveasubscribe is counted…\". */}\n\t\t\t\t<div style={noticeRow}>\n\t\t\t\t\t<span style={noticeBadge}>\n\t\t\t\t\t\t<Badge tone=\"caution\" fontSize={0}>Note</Badge>\n\t\t\t\t\t</span>\n\t\t\t\t\t<Text size={1}>{notice}</Text>\n\t\t\t\t</div>\n\t\t\t</Card>\n\t\t</li>\n\t)\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t{/* A real list element, so a screen reader announces how many caveats there are. */}\n\t\t\t<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 8 }}>\n\t\t\t\t{alwaysShown.map(item)}\n\t\t\t\t{expanded && hidden.map(item)}\n\t\t\t</ul>\n\n\t\t\t{hidden.length > 0 && (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tstyle={disclosure}\n\t\t\t\t\tonClick={() => setExpanded((open) => !open)}\n\t\t\t\t\taria-expanded={expanded}\n\t\t\t\t>\n\t\t\t\t\t{expanded\n\t\t\t\t\t\t? 'Show fewer caveats'\n\t\t\t\t\t\t: `${hidden.length} more ${hidden.length === 1 ? 'caveat' : 'caveats'}`}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n\n/** One column of a sortable table. */\nexport interface SortColumn<Row> {\n\t/** Stable key, also used as the sort key. */\n\tkey: string\n\tlabel: string\n\t/** Right-aligned and sorted high-to-low first, the way a reader expects of a figure. */\n\tnumeric?: boolean\n\t/**\n\t * Value to sort on. Return null for \"no value\" — those always sort last regardless of\n\t * direction, because an unmeasured row is not a small one and must not lead an ascending sort.\n\t */\n\tsortValue: (row: Row) => number | string | null\n\t/**\n\t * Value written to the CSV, when it differs from the sort key.\n\t *\n\t * Defaults to `sortValue`, which is right for a plain count and wrong for anything formatted:\n\t * a rate sorts on 0.4318 and displays 43%, revenue sorts on a bare number and displays a\n\t * currency, and a column with a fallback displays something `sortValue` returns null for.\n\t */\n\texportValue?: (row: Row) => number | string | null\n\t/**\n\t * Keep this column even when every value in it is blank or zero.\n\t *\n\t * For the column that identifies the row — a table whose first column collapsed would be a list\n\t * of numbers belonging to nothing — and for any column whose emptiness is itself the finding.\n\t */\n\talwaysShow?: boolean\n\trender: (row: Row) => React.ReactNode\n}\n\n/**\n * Whether a column holds nothing worth a column.\n *\n * Empty means MEASURED and nothing: every visible row returns zero, or an empty string. A table of\n * a foundry's ten families with a Purchases column reading 0 ten times is the case this exists for\n * — the column is as wide as its heading and says only \"not this one\".\n *\n * NULL DOES NOT COUNT, and that is the whole distinction this package is built on. A column where\n * every value is unavailable is not an empty column, it is the finding that something could not be\n * measured — GA4 attributing no revenue to any source is the loudest thing the acquisition table\n * can tell you, and folding it away would turn the tool's best output into an absence the reader\n * never sees. A column of dashes is meant to be uncomfortable.\n *\n * This is still the one place in the package where a measured zero is treated as nothing, and it\n * is safe only because the table SAYS how many columns it folded and gives them back in one click.\n *\n * @param column - the column under test\n * @param rows - the rows actually on screen, after filtering and exclusion\n */\nexport function columnIsEmpty<Row>(column: SortColumn<Row>, rows: Row[]): boolean {\n\tif (column.alwaysShow) return false\n\t// A table with no rows has no empty columns — it has no columns' worth of evidence either way,\n\t// and folding every column of an empty table leaves a heading strip and nothing under it.\n\tif (rows.length === 0) return false\n\treturn rows.every((row) => {\n\t\tconst value = column.sortValue(row)\n\t\treturn value === 0 || value === ''\n\t})\n}\n\n/**\n * One titled region of a panel.\n *\n * Every section in this tool was a bare `Stack` with a `Heading size={1}` on top, which meant the\n * panel had exactly one level of hierarchy: the tab title, and then eleven things of equal weight.\n * A reader scanning for \"what should I look at\" got no help, because nothing on the page claimed to\n * matter more than anything else — and the two blocks that genuinely are reference material,\n * Configuration and Context, shouted as loudly as the traffic table.\n *\n * `tone` sets the weight; `collapsible` is what actually removes noise. A collapsed section renders\n * NO children at all rather than hiding them with CSS, so it costs nothing to draw and cannot be\n * found by a text search of the page — which is the honest behaviour when the reader has said they\n * do not want it.\n */\nexport function Section({\n\ttitle,\n\tsubtitle,\n\ttone = 'primary',\n\tcollapsible = false,\n\tdefaultOpen = true,\n\tchildren,\n}: {\n\ttitle: string\n\t/** One line under the heading. Longer explanation belongs inside, not here. */\n\tsubtitle?: React.ReactNode\n\t/** `secondary` is for reference material a reader consults rather than reads. */\n\ttone?: 'primary' | 'secondary'\n\t/** Whether the reader can fold it away. */\n\tcollapsible?: boolean\n\t/** Start folded, for material that is worth having and not worth reading every time. */\n\tdefaultOpen?: boolean\n\tchildren: React.ReactNode\n}): React.ReactElement {\n\tconst [open, setOpen] = React.useState(defaultOpen)\n\tconst bodyId = React.useId()\n\tconst shown = !collapsible || open\n\n\treturn (\n\t\t<section style={sectionBox}>\n\t\t\t<div style={sectionHeader}>\n\t\t\t\t<Stack space={1}>\n\t\t\t\t\t<Heading size={tone === 'primary' ? 1 : 0} style={tone === 'primary' ? primaryTitle : secondaryTitle}>\n\t\t\t\t\t\t{title}\n\t\t\t\t\t</Heading>\n\t\t\t\t\t{subtitle && <Text size={1} muted>{subtitle}</Text>}\n\t\t\t\t</Stack>\n\t\t\t\t{collapsible && (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tstyle={sectionToggle}\n\t\t\t\t\t\taria-expanded={open}\n\t\t\t\t\t\taria-controls={bodyId}\n\t\t\t\t\t\tonClick={() => setOpen((was) => !was)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{open ? 'Hide' : 'Show'}\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t{/* A Stack, not a bare div. See the note on sectionBox. */}\n\t\t\t{shown && <div id={bodyId} style={sectionBody}>{children}</div>}\n\t\t</section>\n\t)\n}\n\n/**\n * A section heading, without the fold.\n *\n * Most sections are not collapsible — they are the content — but they still need the hierarchy the\n * panel had none of: one weight for what you read, a quieter one for what you consult, and a rule\n * so the eye can find where one region ends and the next begins. `Section` uses the same two\n * treatments; this is for the blocks whose markup already owns its own stacking.\n */\nexport function SectionTitle({\n\ttitle,\n\ttone = 'primary',\n}: {\n\ttitle: string\n\t/** `secondary` is for reference material a reader consults rather than reads. */\n\ttone?: 'primary' | 'secondary'\n}): React.ReactElement {\n\treturn (\n\t\t<div style={sectionHeader}>\n\t\t\t<Heading size={tone === 'primary' ? 1 : 0} style={tone === 'primary' ? primaryTitle : secondaryTitle}>\n\t\t\t\t{title}\n\t\t\t</Heading>\n\t\t</div>\n\t)\n}\n\n/**\n * A part drawn INSIDE its whole, rather than beside it.\n *\n * The tool's central fact — GA4 sees a fifth of the traffic Vercel counts — was drawn as two peer\n * bars scaled to the larger of the pair. Vercel is always the larger, so its bar was always full\n * width on every site and every range: it carried no information at all, while a caption underneath\n * had to explain in words that one bar was \"a subset of the bar above, not a rival measurement\".\n * Two bars side by side encode RIVALS. The encoding asserted the opposite of the truth and the\n * prose was there to take it back.\n *\n * One bar says it without the sentence: the whole is the track, the part is filled inside it, and\n * what is left over is the quantity the reader came for.\n *\n * REFUSES when the part exceeds the whole. That happens for real — GA4 counts more than Vercel\n * wherever Vercel's collection started later than the range, which is routine on MCKL — and\n * containment is then simply the wrong picture. The caller falls back rather than this component\n * drawing a fill wider than its own track.\n */\n/**\n * Whether one figure is genuinely contained by another, so a part-inside-whole bar is honest.\n *\n * False where either side is unmeasured, and false where the part exceeds the whole — which is\n * real rather than hypothetical: GA4 counts more than Vercel wherever Vercel's collection started\n * after the range began, as it does on MCKL. Exported so the caller can choose its encoding rather\n * than discovering a null mid-render.\n *\n * @param whole - the complete count\n * @param part - the lossier count that should sit inside it\n */\nexport function isContainment(whole: MetricValue | undefined, part: MetricValue | undefined): boolean {\n\tconst w = valueOrNull(whole)\n\tconst p = valueOrNull(part)\n\treturn w !== null && p !== null && w > 0 && p <= w\n}\n\nexport function ContainmentBar({\n\twholeLabel,\n\twhole,\n\tpartLabel,\n\tpart,\n\tmissingLabel,\n}: {\n\twholeLabel: string\n\twhole: MetricValue\n\tpartLabel: string\n\tpart: MetricValue\n\t/** What the unfilled remainder means, e.g. \"not seen by GA4\". */\n\tmissingLabel: string\n}): React.ReactElement | null {\n\tconst wholeValue = valueOrNull(whole)\n\tconst partValue = valueOrNull(part)\n\n\t// Either side unmeasured, or the part larger than the whole: not a containment.\n\tif (!isContainment(whole, part)) return null\n\tif (wholeValue === null || partValue === null) return null\n\n\tconst share = partValue / wholeValue\n\tconst missing = wholeValue - partValue\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t<div style={barHeader}>\n\t\t\t\t<Text size={1}>{wholeLabel}</Text>\n\t\t\t\t<Text size={1} weight=\"medium\">{formatCount(wholeValue)}</Text>\n\t\t\t</div>\n\t\t\t{/* The track IS the whole. Nothing here is scaled to a maximum computed from the pair,\n\t\t\t so neither bar can be full width by construction. */}\n\t\t\t<div aria-hidden=\"true\" style={{ ...barTrack, background: mark('bar.fill'), height: 14 }}>\n\t\t\t\t<div style={{ height: '100%', width: `${share * 100}%`, background: mark('bar.seen'), borderRadius: 2 }} />\n\t\t\t</div>\n\t\t\t<div style={barHeader}>\n\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t{partLabel}: {formatCount(partValue)} ({formatPercent(share, 0)})\n\t\t\t\t</Text>\n\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t{formatCount(missing)} {missingLabel}\n\t\t\t\t</Text>\n\t\t\t</div>\n\t\t</Stack>\n\t)\n}\n\n/**\n * Three independent estimates of one number, on one axis.\n *\n * The capture model computes how much of reality GA4 is seeing three separate ways — against the\n * order book, against email clicks, against Vercel — and `capture.ts` is explicit that they are\n * deliberately NOT averaged, because the DISAGREEMENT between them is the useful output. Rendered\n * as three cards in a grid, that disagreement is a subtraction the reader performs by eye, and the\n * grid reorders itself by pane width so even their sequence is unstable.\n *\n * On one axis it is a distance. Three dots, a rule at 100%, and each dot carrying the interval its\n * own sample size leaves — so the estimate built on seven orders draws a bar most of the axis wide\n * and the reader can see, without being told, which of the three to discount.\n *\n * That interval already existed. `samplingInterval` has been in capture.ts throughout, documented\n * with \"printing that is the point\", and was drawn nowhere.\n *\n * No d3-shape: an axis, three rows, a rule. The scale opens past 100% because a rate above 1 is a\n * real fault this package reports rather than an impossibility — the same reason the coverage row\n * is no longer clamped.\n */\n/** What the plot needs of an estimate. Matches the core model's CaptureEstimate structurally. */\nexport interface EstimateForPlot {\n\tbasis: string\n\trate: number\n\tobserved: number\n\tactual: number\n\tnote: string\n}\n\nexport function EstimateDotPlot<E extends EstimateForPlot>({\n\testimates,\n\tlabelFor,\n\tintervalFor,\n}: {\n\testimates: ReadonlyArray<E>\n\t/** How to name a basis to the reader. */\n\tlabelFor: (basis: E['basis']) => string\n\t/**\n\t * The sampling interval for an estimate, from the core capture model.\n\t *\n\t * Takes the whole estimate rather than a narrowed shape: a structural `{ rate, actual }` looks\n\t * tidier and cannot be satisfied by `samplingInterval`, whose parameter is the full\n\t * CaptureEstimate — so the caller would have to wrap it for no benefit.\n\t */\n\tintervalFor: (estimate: E) => { low: number; high: number }\n}): React.ReactElement | null {\n\tif (estimates.length === 0) return null\n\n\tconst intervals = estimates.map((e) => intervalFor(e))\n\t// Opens past 1 when anything exceeds it, so an over-count is visible rather than pinned to the\n\t// right edge reading as \"perfect\".\n\tconst top = Math.max(1, ...estimates.map((e) => e.rate), ...intervals.map((i) => i.high))\n\tconst pct = (v: number) => `${(Math.max(0, Math.min(1, v / top))) * 100}%`\n\n\treturn (\n\t\t<Stack space={3}>\n\t\t\t{estimates.map((estimate, index) => {\n\t\t\t\tconst interval = intervals[index] as { low: number; high: number }\n\t\t\t\tconst wide = interval.high - interval.low > 0.4\n\t\t\t\treturn (\n\t\t\t\t\t<Stack key={estimate.basis} space={2}>\n\t\t\t\t\t\t<div style={barHeader}>\n\t\t\t\t\t\t\t<Text size={1}>{labelFor(estimate.basis)}</Text>\n\t\t\t\t\t\t\t<Text size={1} weight=\"medium\">{formatPercent(estimate.rate, 0)}</Text>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div aria-hidden=\"true\" style={estimateTrack}>\n\t\t\t\t\t\t\t{/* The rule first, so a dot at 100% sits on top of it rather than under. */}\n\t\t\t\t\t\t\t<div style={{ ...estimateRule, left: pct(1) }} />\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t...estimateInterval,\n\t\t\t\t\t\t\t\t\tleft: pct(interval.low),\n\t\t\t\t\t\t\t\t\twidth: `calc(${pct(interval.high)} - ${pct(interval.low)})`,\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<div style={{ ...estimateDot, left: pct(estimate.rate) }} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t{formatCount(estimate.observed)} of {formatCount(estimate.actual)}\n\t\t\t\t\t\t\t{/* Said in words as well as drawn, because the bar is aria-hidden and because\n\t\t\t\t\t\t\t \"this one is too thin to lean on\" is the conclusion, not the picture. */}\n\t\t\t\t\t\t\t{wide && ' — too small a sample to lean on'}\n\t\t\t\t\t\t\t{/* EACH ESTIMATE'S OWN BIAS, which was written, required by this component's\n\t\t\t\t\t\t\t props type, and rendered nowhere.\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t Three dots share an axis so their DISAGREEMENT is legible — but each is\n\t\t\t\t\t\t\t wrong in a different direction, and that direction is the only thing telling\n\t\t\t\t\t\t\t a reader which dot to move toward. Pageviews is a ceiling; orders moves ~14\n\t\t\t\t\t\t\t points on one more GA4 purchase; email measures tagging as much as capture.\n\t\t\t\t\t\t\t Without them a reader seeing 45% and 20% has a spread and no way to resolve\n\t\t\t\t\t\t\t it — and on a Week range there is often only ONE dot, no discrepancy card,\n\t\t\t\t\t\t\t and nothing at all saying what that dot means. */}\n\t\t\t\t\t\t\t{estimate.note && <span style={estimateNote}>{estimate.note}</span>}\n\t\t\t\t\t\t\t{/* The CAUSE, not just the fact. \"Over 100%\" states the reading; \"usually a tag\n\t\t\t\t\t\t\t firing twice\" is the half a reader can act on, and dropping it when this\n\t\t\t\t\t\t\t moved from cards to a plot would have been a quiet loss — caught by the\n\t\t\t\t\t\t\t test that pinned it. */}\n\t\t\t\t\t\t\t{estimate.rate > 1.05 && ' — over 100%, so GA4 is counting more than the source it is checked against. That is usually a tag firing twice, not extra traffic.'}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t)\n\t\t\t})}\n\t\t\t{/* The shared footer is gone: three notes that each say what their own estimate is make a\n\t\t\t sentence saying \"each is a separate way of asking the same question\" redundant. The one\n\t\t\t fact the notes cannot carry is what the rule means, so that is all this says now. */}\n\t\t\t<Text size={0} muted>The rule marks 100%.</Text>\n\t\t</Stack>\n\t)\n}\n\n/** One licence line, as the order book records it. */\nexport interface LicenceLadderRow {\n\ttype: string\n\ttier: string\n\t/** The tier's rung on the price ladder. Rows are read in this order, never by size. */\n\ttierValue: number\n\tterm: string\n\torders: number\n\t/** The order total split evenly across the licences on it — a share, not a price. */\n\trevenue: number | null\n}\n\n/**\n * Licence sales, faceted by type, with each type's tiers in ladder order.\n *\n * Three dimensions, three channels: type is a facet, tier is vertical position within it, term is a\n * sub-row. The previous encoding flattened all three into one `type · tier` string on a single axis\n * and then sorted that axis by value — which is the one thing an ordinal ladder cannot survive, and\n * which put the two term-rows of a single tier as far apart as their values happened to fall. The\n * caption above it asked whether a tier that sells at one year also sells at perpetual; the drawing\n * separated exactly those two rows.\n *\n * Bars are ORDERS and share one scale across every facet, so a rung in Desktop is comparable with a\n * rung in Web. Orders because they are exact and unapportioned: a row's revenue is its order's total\n * divided evenly by the licences on that order, which is a share rather than a price, and ranking a\n * ladder by it ranks the split as much as the sales.\n */\nexport function LicenceLadder({ rows, currency }: {\n\trows: readonly LicenceLadderRow[]\n\tcurrency: string | null\n}): React.ReactElement | null {\n\tif (rows.length === 0) return null\n\n\t// One scale for the whole chart. Per-facet scaling would make a one-order type look as busy as\n\t// the type carrying the business.\n\tconst peak = Math.max(1, ...rows.map((row) => row.orders))\n\n\tconst types: Array<{ type: string; rungs: LicenceLadderRow[] }> = []\n\tfor (const row of rows) {\n\t\tconst facet = types.find((t) => t.type === row.type)\n\t\tif (facet) facet.rungs.push(row)\n\t\telse types.push({ type: row.type, rungs: [row] })\n\t}\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t{types.map((facet) => (\n\t\t\t\t<Stack key={facet.type} space={2}>\n\t\t\t\t\t<Text size={1} weight=\"semibold\">{facet.type}</Text>\n\t\t\t\t\t{facet.rungs.map((row, index) => {\n\t\t\t\t\t\t// The tier name prints once per rung; a term sharing the tier above it is\n\t\t\t\t\t\t// indented under it, which is what makes the two terms of one tier read as a pair.\n\t\t\t\t\t\tconst sameTierAsAbove = index > 0 && facet.rungs[index - 1]?.tier === row.tier\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div key={`${row.tier}\\u0000${row.term}`} style={ladderRow}>\n\t\t\t\t\t\t\t\t<Text size={0} muted>{sameTierAsAbove ? '' : row.tier}</Text>\n\t\t\t\t\t\t\t\t<Text size={0} muted>{row.term}</Text>\n\t\t\t\t\t\t\t\t<div aria-hidden=\"true\" style={ladderTrack}>\n\t\t\t\t\t\t\t\t\t<div style={{ ...barFill, width: `${Math.max(2, (row.orders / peak) * 100)}%` }} />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<Text size={0} style={ladderValue}>\n\t\t\t\t\t\t\t\t\t{formatCount(row.orders)}\n\t\t\t\t\t\t\t\t\t{row.revenue !== null && (\n\t\t\t\t\t\t\t\t\t\t<span style={ladderMoney}> · {formatMoney(row.revenue, currency)}</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</Stack>\n\t\t\t))}\n\t\t</Stack>\n\t)\n}\n\n/**\n * A ratio you can turn over to see what it is made of.\n *\n * At a foundry's volumes a derived rate is the least trustworthy thing on the page and often the\n * most quotable: \"337 visitors per order\" reads like a fact, and one more sale moves it to 295. The\n * package's answer everywhere else is to withhold — a rate below its denominator floor is simply\n * not printed. That is right for a rate the reader did not ask for and wrong for one they did,\n * because withholding leaves them with nothing.\n *\n * So: show it, and make its basis one click away. The counts face is not a footnote — it is the\n * same figure told honestly, and a reader who flips it once learns for themselves how thin the\n * arithmetic is. That is a better lesson than a caveat they skim.\n *\n * The toggle is per-card state and deliberately not persisted. It answers \"what is this made of\"\n * in the moment; remembering the answer across sessions would leave a reader staring at raw counts\n * with no memory of asking for them.\n */\nexport function RatioFigure({\n\tvalue,\n\tformat,\n\tparts,\n\tsize = 4,\n\tunavailable,\n}: {\n\t/** The ratio itself. Null when it could not be computed. */\n\tvalue: number | null\n\t/** How to render the ratio. */\n\tformat: (value: number) => string\n\t/** What it is made of, in reading order: numerator then denominator. */\n\tparts: ReadonlyArray<{ label: string; value: number | null; format?: (value: number) => string }>\n\tsize?: 0 | 1 | 2 | 3 | 4\n\t/** Shown in place of the figure when the ratio is null. */\n\tunavailable?: React.ReactNode\n}): React.ReactElement {\n\tconst [showParts, setShowParts] = React.useState(false)\n\n\t// Nothing to turn over: if the ratio could not be computed, the parts are what there is.\n\tconst canToggle = value !== null && parts.every((p) => p.value !== null)\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t<div style={ratioRow}>\n\t\t\t\t{showParts && canToggle\n\t\t\t\t\t? (\n\t\t\t\t\t\t<Stack space={1}>\n\t\t\t\t\t\t\t{parts.map((part) => (\n\t\t\t\t\t\t\t\t<Text key={part.label} size={size > 1 ? 2 : size}>\n\t\t\t\t\t\t\t\t\t{(part.format ?? formatCount)(part.value as number)}{' '}\n\t\t\t\t\t\t\t\t\t<Text as=\"span\" size={0} muted>{part.label}</Text>\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</Stack>\n\t\t\t\t\t)\n\t\t\t\t\t: value === null\n\t\t\t\t\t\t? unavailable\n\t\t\t\t\t\t: <Text size={size}>{format(value)}</Text>}\n\t\t\t\t{canToggle && (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tstyle={ratioToggle}\n\t\t\t\t\t\taria-pressed={showParts}\n\t\t\t\t\t\t// Named for what it reveals, not \"toggle\": a reader should know what they will\n\t\t\t\t\t\t// get before pressing, and the label is the only thing telling them.\n\t\t\t\t\t\taria-label={showParts ? 'Show the rate' : 'Show the counts behind this rate'}\n\t\t\t\t\t\tonClick={() => setShowParts((was) => !was)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{showParts ? 'rate' : 'counts'}\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</Stack>\n\t)\n}\n\n/** Props for SortableTable. */\nexport interface SortableTableProps<Row> {\n\tcaption: string\n\tcolumns: Array<SortColumn<Row>>\n\trows: Row[]\n\trowKey: (row: Row) => string\n\t/** Column sorted on first load. Defaults to the server's own ordering. */\n\tinitialSort?: string\n\t/**\n\t * The text a row is matched against when filtering. Omit to render no filter box.\n\t *\n\t * Sorting was the tool's entire interaction budget, which meant a reader who spotted an\n\t * unrelated site's traffic in the table had no way to take it out and see what was left.\n\t */\n\tfilterOn?: (row: Row) => string\n\t/** Placeholder for the filter box, naming what is searched. */\n\tfilterPlaceholder?: string\n\t/** Base filename for the CSV export. Omit to render no export control. */\n\texportName?: string\n\t/** Shown under the table when the server truncated the row set. */\n\ttruncatedNote?: string\n\t/** Allow per-row exclusion even without a filter box. */\n\tonExclude?: boolean\n}\n\n/**\n * A table whose columns sort.\n *\n * Both data tables arrived in one server-chosen order — sessions descending, views descending — so\n * the only question they could answer was the one that order encoded. Sorting by any column turns\n * the same rows into several different questions: which family is tested most relative to views,\n * which source is least attributed, which typeface sells without being looked at.\n *\n * Rows with no value for the active column sort last in both directions. That is deliberate: an\n * unavailable metric is not a zero, and letting it lead an ascending sort would restate exactly the\n * confusion the MetricValue type exists to prevent.\n */\nexport function SortableTable<Row>({\n\tcaption,\n\tcolumns,\n\trows,\n\trowKey,\n\tinitialSort,\n\tfilterPlaceholder,\n\tfilterOn,\n\texportName,\n\ttruncatedNote,\n\tonExclude,\n}: SortableTableProps<Row>): React.ReactElement {\n\tconst [sort, setSort] = React.useState<{ key: string; desc: boolean } | null>(\n\t\tinitialSort ? { key: initialSort, desc: true } : null,\n\t)\n\tconst [query, setQuery] = React.useState('')\n\tconst [excluded, setExcluded] = React.useState<ReadonlySet<string>>(() => new Set())\n\tconst [copied, setCopied] = React.useState<'idle' | 'done' | 'failed'>('idle')\n\t// Off by default: the empty columns are the noise this hides. The control below says how many,\n\t// so the reduction is announced rather than silent.\n\tconst [showEmpty, setShowEmpty] = React.useState(false)\n\t// Held so repeated clicks cannot stack timers and revert the label early, and so the pending\n\t// one is cleared on unmount.\n\tconst copyTimer = React.useRef<number | null>(null)\n\tReact.useEffect(() => () => {\n\t\tif (copyTimer.current !== null) window.clearTimeout(copyTimer.current)\n\t}, [])\n\n\tconst active = sort ? columns.find((c) => c.key === sort.key) : undefined\n\n\t// Filtering and exclusion both happen before sorting, so the ranking is of what is shown.\n\tconst visible = React.useMemo(() => {\n\t\tconst needle = query.trim().toLowerCase()\n\t\treturn rows.filter((row) => {\n\t\t\tif (excluded.has(rowKey(row))) return false\n\t\t\tif (!needle || !filterOn) return true\n\t\t\treturn filterOn(row).toLowerCase().includes(needle)\n\t\t})\n\t}, [rows, query, excluded, filterOn, rowKey])\n\n\t/**\n\t * The columns actually drawn.\n\t *\n\t * Computed from `visible`, not from every row: narrowing the range or filtering the table can\n\t * empty a column, and a column that is empty for what is on screen is noise for what is on\n\t * screen. The first column is always kept — it names the row — as is whatever is being sorted\n\t * on, because folding the sorted column would leave the order unexplained.\n\t */\n\tconst emptyColumns = React.useMemo(\n\t\t() => columns.filter((column, index) =>\n\t\t\tindex > 0 && column.key !== sort?.key && columnIsEmpty(column, visible),\n\t\t),\n\t\t[columns, visible, sort?.key],\n\t)\n\tconst shown = showEmpty || emptyColumns.length === 0\n\t\t? columns\n\t\t: columns.filter((column) => !emptyColumns.includes(column))\n\n\tconst ordered = React.useMemo(() => {\n\t\tconst rows = visible\n\t\tif (!active || !sort) return rows\n\t\tconst copy = [...rows]\n\t\tcopy.sort((a, b) => {\n\t\t\tconst av = active.sortValue(a)\n\t\t\tconst bv = active.sortValue(b)\n\t\t\t// Missing values sink, whichever way the column is pointing.\n\t\t\tif (av === null && bv === null) return 0\n\t\t\tif (av === null) return 1\n\t\t\tif (bv === null) return -1\n\t\t\tconst cmp = typeof av === 'number' && typeof bv === 'number'\n\t\t\t\t? av - bv\n\t\t\t\t: String(av).localeCompare(String(bv))\n\t\t\treturn sort.desc ? -cmp : cmp\n\t\t})\n\t\treturn copy\n\t}, [visible, active, sort])\n\n\tconst toggle = (column: SortColumn<Row>) => {\n\t\tsetSort((current) => {\n\t\t\tif (current?.key !== column.key) return { key: column.key, desc: Boolean(column.numeric) }\n\t\t\treturn { key: column.key, desc: !current.desc }\n\t\t})\n\t}\n\n\t/**\n\t * Copy the visible rows to the clipboard as CSV.\n\t *\n\t * Clipboard rather than a download: the artifact viewer and the Studio both sandbox\n\t * script-initiated downloads, and \"paste into the email you were already writing\" is the actual\n\t * task. Exports what is on screen — filtered, sorted, minus exclusions — because a copy that\n\t * silently differs from the table above it is worse than none.\n\t */\n\tconst copyCsv = async () => {\n\t\t// What is on screen, columns included. A CSV carrying columns the table folded would not be\n\t\t// the thing the button says it copies, and every one of them would be a column of zeros.\n\t\tconst header = shown.map((column) => column.label)\n\t\tconst lines = [header, ...ordered.map((row) => shown.map((column) => {\n\t\t\t// `exportValue` where a column defines one, because `sortValue` is a SORT KEY and is\n\t\t\t// routinely a different thing from what the cell shows: engagement sorts on 0.4318 and\n\t\t\t// displays 43%, revenue sorts on a bare number and displays a currency, and campaign\n\t\t\t// sorts on null where the cell shows the medium it falls back to. The comment below\n\t\t\t// promised a copy of what is on screen and delivered the sort keys instead.\n\t\t\tconst value = column.exportValue ? column.exportValue(row) : column.sortValue(row)\n\t\t\treturn value === null || value === undefined ? '' : String(value)\n\t\t}))]\n\n\t\tconst csv = lines.map((cells) => cells.map(csvCell).join(',')).join('\\n')\n\n\t\ttry {\n\t\t\tawait navigator.clipboard.writeText(csv)\n\t\t\tsetCopied('done')\n\t\t\tif (copyTimer.current !== null) window.clearTimeout(copyTimer.current)\n\t\t\tcopyTimer.current = window.setTimeout(() => setCopied('idle'), 2000)\n\t\t} catch {\n\t\t\t// Reachable, not exotic: navigator.clipboard is undefined in any non-secure context, so\n\t\t\t// a Studio served over plain http or on an internal IP lands here every time. It used\n\t\t\t// to set the flag back to idle, which rendered as the button doing nothing at all.\n\t\t\tsetCopied('failed')\n\t\t\tif (copyTimer.current !== null) window.clearTimeout(copyTimer.current)\n\t\t\tcopyTimer.current = window.setTimeout(() => setCopied('idle'), 4000)\n\t\t}\n\t}\n\n\t// Rows the FILTER hid, and only those. `rows.length - visible.length` counted the excluded ones\n\t// too, because `visible` filters on both — so excluding three rows with no filter text rendered\n\t// \"3 rows hidden · 3 excluded\", and the one line whose job is saying what you are looking at was\n\t// arithmetically wrong.\n\tconst excludedInWindow = rows.filter((row) => excluded.has(rowKey(row))).length\n\tconst hiddenCount = rows.length - visible.length - excludedInWindow\n\t// Counted independently of whether the excluded rows exist in THIS window. Exclusions now\n\t// survive a refetch, so excluding a bot referrer on Quarter and then narrowing to a week where\n\t// it has no sessions left the exclusion armed and the control gone — silently hiding rows on\n\t// the way back out.\n\tconst excludedCount = excluded.size\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t{(filterOn || exportName || emptyColumns.length > 0) && (\n\t\t\t\t<div style={tableControls}>\n\t\t\t\t\t{filterOn && (\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"search\"\n\t\t\t\t\t\t\tvalue={query}\n\t\t\t\t\t\t\tplaceholder={filterPlaceholder ?? 'Filter rows'}\n\t\t\t\t\t\t\taria-label={filterPlaceholder ?? 'Filter rows'}\n\t\t\t\t\t\t\tstyle={filterInput}\n\t\t\t\t\t\t\tonChange={(e) => setQuery(e.currentTarget.value)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t{(hiddenCount > 0 || excludedCount > 0) && (\n\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t{hiddenCount > 0 && <>{hiddenCount} row{hiddenCount === 1 ? '' : 's'} hidden</>}\n\t\t\t\t\t\t\t{excludedCount > 0 && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t{hiddenCount > 0 ? ' · ' : ''}\n\t\t\t\t\t\t\t\t\t{excludedCount} excluded{' '}\n\t\t\t\t\t\t\t\t\t<button type=\"button\" style={inlineLink} onClick={() => setExcluded(new Set())}>\n\t\t\t\t\t\t\t\t\t\trestore\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)}\n\t\t\t\t\t<span style={{ flex: 1 }} />\n\t\t\t\t\t{/* Named with a count, not a bare \"show all\". A reader has to be able to tell the\n\t\t\t\t\t difference between a table with four columns and a table showing four of\n\t\t\t\t\t seven — otherwise this is the tool deciding what the data says. */}\n\t\t\t\t\t{emptyColumns.length > 0 && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tstyle={tableControlButton}\n\t\t\t\t\t\t\taria-pressed={showEmpty}\n\t\t\t\t\t\t\tonClick={() => setShowEmpty((open) => !open)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{showEmpty\n\t\t\t\t\t\t\t\t? `Hide ${emptyColumns.length} empty column${emptyColumns.length === 1 ? '' : 's'}`\n\t\t\t\t\t\t\t\t: `Show ${emptyColumns.length} empty column${emptyColumns.length === 1 ? '' : 's'}`}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t\t{exportName && (\n\t\t\t\t\t\t<button type=\"button\" style={tableControlButton} onClick={() => void copyCsv()}>\n\t\t\t\t\t\t\t{copied === 'done' ? 'Copied' : copied === 'failed' ? 'Could not copy' : 'Copy as CSV'}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t<Card radius={2} tone=\"transparent\" style={tableWrapper}>\n\t\t\t<table style={tableBase}>\n\t\t\t\t<caption style={visuallyHidden}>{caption}</caption>\n\t\t\t\t<thead>\n\t\t\t\t\t<tr>\n\t\t\t\t\t\t{shown.map((column) => {\n\t\t\t\t\t\t\tconst isActive = sort?.key === column.key\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<th\n\t\t\t\t\t\t\t\t\tkey={column.key}\n\t\t\t\t\t\t\t\t\tscope=\"col\"\n\t\t\t\t\t\t\t\t\tstyle={column.numeric ? headCellNumeric : headCell}\n\t\t\t\t\t\t\t\t\taria-sort={isActive ? (sort?.desc ? 'descending' : 'ascending') : 'none'}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<button type=\"button\" style={sortButton(Boolean(column.numeric))} onClick={() => toggle(column)}>\n\t\t\t\t\t\t\t\t\t\t{column.label}\n\t\t\t\t\t\t\t\t\t\t{/* An arrow, not colour alone, so the sorted column is legible\n\t\t\t\t\t\t\t\t\t\t to anyone. A dot marks the unsorted columns as sortable. */}\n\t\t\t\t\t\t\t\t\t\t<span aria-hidden=\"true\" style={sortMark}>\n\t\t\t\t\t\t\t\t\t\t\t{isActive ? (sort?.desc ? '▼' : '▲') : '↕'}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t})}\n\t\t\t\t\t</tr>\n\t\t\t\t</thead>\n\t\t\t\t<tbody>\n\t\t\t\t\t{ordered.map((row) => {\n\t\t\t\t\t\tconst key = rowKey(row)\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<tr key={key}>\n\t\t\t\t\t\t\t\t{shown.map((column, index) => {\n\t\t\t\t\t\t\t\t\tconst content = column.render(row)\n\t\t\t\t\t\t\t\t\treturn index === 0 ? (\n\t\t\t\t\t\t\t\t\t\t<th key={column.key} scope=\"row\" style={bodyCell}>\n\t\t\t\t\t\t\t\t\t\t\t<span style={firstCell}>\n\t\t\t\t\t\t\t\t\t\t\t\t{content}\n\t\t\t\t\t\t\t\t\t\t\t\t{/* Offered where the row set is the reader's to shape. A chart's\n\t\t\t\t\t\t\t\t\t\t\t\t data table passes neither, because excluding a date there\n\t\t\t\t\t\t\t\t\t\t\t\t would visibly do nothing to the chart above it — an\n\t\t\t\t\t\t\t\t\t\t\t\t affordance that does nothing is worse than none. */}\n\t\t\t\t\t\t\t\t\t\t\t\t{filterOn || onExclude ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t// Per-row exclusion, because the fix for a contaminated\n\t\t\t\t\t\t\t\t\t\t\t\t\t// table is to take the bad row out and see what the\n\t\t\t\t\t\t\t\t\t\t\t\t\t// rest looks like. Rendered on every row rather than\n\t\t\t\t\t\t\t\t\t\t\t\t\t// on hover so it is reachable by keyboard and touch.\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={excludeButton}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={`Exclude ${key}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttitle={`Exclude ${key} from this table`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => setExcluded((current) => new Set(current).add(key))}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t×\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<td key={column.key} style={column.numeric ? bodyCellNumeric : bodyCell}>{content}</td>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t\t{ordered.length === 0 && (\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td colSpan={columns.length} style={bodyCell}>\n\t\t\t\t\t\t\t\t{/* Which emptiness this is. It always blamed the filter, so a site with no\n\t\t\t\t\t\t\t\t orders, or an unconfigured GA4, was told it had filtered its own data\n\t\t\t\t\t\t\t\t away — the reader hunting for a control they never touched. */}\n\t\t\t\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\t\t\t\t{rows.length === 0\n\t\t\t\t\t\t\t\t\t\t? 'Nothing to show for this period.'\n\t\t\t\t\t\t\t\t\t\t: query.trim() || excludedInWindow > 0\n\t\t\t\t\t\t\t\t\t\t\t? 'No rows match this filter.'\n\t\t\t\t\t\t\t\t\t\t\t: 'Nothing to show for this period.'}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t)}\n\t\t\t\t</tbody>\n\t\t\t</table>\n\t\t</Card>\n\n\t\t{truncatedNote && <Text size={0} muted>{truncatedNote}</Text>}\n\t\t</Stack>\n\t)\n}\n\n/**\n * Escape one CSV cell.\n *\n * Quotes the separators, and neutralises a leading `=`, `+`, `-`, `@`, tab or carriage return.\n * That second part matters because source and campaign values are attacker-influenceable from\n * outside: anyone can request the site with `?utm_campaign==HYPERLINK(\"...\")`, GA4 stores it, and\n * pasting the export into a spreadsheet would evaluate it as a live formula.\n */\nfunction csvCell(cell: string): string {\n\tconst neutralised = /^[=+\\-@\\t\\r]/.test(cell) ? `'${cell}` : cell\n\treturn /[\",\\n]/.test(neutralised) ? `\"${neutralised.replace(/\"/g, '\"\"')}\"` : neutralised\n}\n\n/** The controls above a table: filter, hidden-row count, export. */\nconst tableControls: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'center',\n\tgap: SPACE.block,\n\tflexWrap: 'wrap',\n}\n\n/** A section: its heading, then its body, at group spacing. */\nconst sectionBox: React.CSSProperties = { display: 'grid', gap: SPACE.group }\n\n/**\n * A section's body, which owns the rhythm between its own children.\n *\n * This is the line that had `Section` used four times against `SectionTitle`'s eight.\n */\nconst sectionBody: React.CSSProperties = { display: 'grid', gap: SPACE.group }\n\n/** Title and its control on one line, the control pushed to the trailing edge. */\nconst sectionHeader: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'flex-start',\n\tjustifyContent: 'space-between',\n\tgap: 12,\n\t// 0.35, not 0.22. This rule was the faintest line in the file and the only thing separating one\n\t// section from the next — while every button drew at 0.30. A \"Hide\" control outranking the\n\t// boundary between two regions of the panel is the hierarchy upside down.\n\tborderBottom: '1px solid var(--card-border-color, rgba(128,128,128,0.35))',\n\tpaddingBottom: 6,\n}\n\n/** A section the reader is meant to read. */\nconst primaryTitle: React.CSSProperties = { margin: 0, lineHeight: 1.3 }\n\n/**\n * A section the reader consults.\n *\n * Smaller and quieter, and the difference has to be visible at a glance or the hierarchy this\n * component exists to create is just two words in a prop.\n */\nconst secondaryTitle: React.CSSProperties = {\n\tmargin: 0,\n\tlineHeight: 1.3,\n\ttextTransform: 'uppercase',\n\tletterSpacing: '0.07em',\n\topacity: 0.75,\n}\n\n/** The fold control. Reads as a control, not as a heading. */\nconst sectionToggle: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.22))',\n\tborderRadius: 3,\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: '0.8em',\n\tpadding: '2px 9px',\n\tcursor: 'pointer',\n\tflex: '0 0 auto',\n}\n\n/**\n * Two blocks side by side on a wide pane, stacked on a narrow one.\n *\n * The minimum is deliberately large: a table squeezed into half of a narrow Studio pane is worse\n * than the same table full width, so the pair splits only when there is genuinely room for both.\n * Like `cardGrid`, it responds to the PANE rather than the viewport, because the panel is a\n * resizable region inside a Studio that is sometimes inside an iframe.\n */\nexport const splitGrid: React.CSSProperties = {\n\tdisplay: 'grid',\n\tgridTemplateColumns: 'repeat(auto-fit, minmax(min(26rem, 100%), 1fr))',\n\tgap: 24,\n\talignItems: 'start',\n}\n\n/** The axis a capture estimate is plotted on. Relative, so the marks position against it. */\nconst estimateTrack: React.CSSProperties = {\n\tposition: 'relative',\n\theight: 18,\n\tborderRadius: 2,\n\tbackground: mark('bar.track'),\n}\n\n/** One estimate's own caveat, on its own line beneath the counts it qualifies. */\nconst estimateNote: React.CSSProperties = { display: 'block', marginTop: 2, opacity: 0.85 }\n\n/** The 100% reference. A capture rate means nothing without it. */\nconst estimateRule: React.CSSProperties = {\n\tposition: 'absolute',\n\ttop: 0,\n\tbottom: 0,\n\twidth: 1,\n\tbackground: mark('estimate.rule'),\n}\n\n/** How wide the sample leaves this estimate. */\nconst estimateInterval: React.CSSProperties = {\n\tposition: 'absolute',\n\ttop: 6,\n\theight: 6,\n\tborderRadius: 3,\n\tbackground: mark('estimate.interval'),\n}\n\n/** The estimate itself. Its own boundary, which is why the interval may sit under 3:1. */\nconst estimateDot: React.CSSProperties = {\n\tposition: 'absolute',\n\ttop: 4,\n\twidth: 10,\n\theight: 10,\n\tmarginLeft: -5,\n\tborderRadius: '50%',\n\tbackground: mark('estimate.dot'),\n}\n\n\n\n\n\n\n\n\n\n\n/** A ratio and its toggle, sharing a baseline. */\nconst ratioRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'baseline',\n\tgap: SPACE.block,\n\tflexWrap: 'wrap',\n}\n\n/** The control that turns a ratio over. Quiet: it is an affordance, not a finding. */\nconst ratioToggle: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.22))',\n\tborderRadius: 3,\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: '0.72em',\n\tpadding: '1px 7px',\n\tcursor: 'pointer',\n\topacity: 0.75,\n}\n\n/** The filter box. */\nconst filterInput: React.CSSProperties = {\n\tfont: 'inherit',\n\tfontSize: '0.85em',\n\tpadding: '4px 8px',\n\tborderRadius: 3,\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.22))',\n\tbackground: 'transparent',\n\tcolor: 'inherit',\n\tminWidth: 160,\n}\n\n/** A control sitting alongside a table, e.g. the CSV copy. */\nconst tableControlButton: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: '1px solid var(--card-border-color, rgba(128,128,128,0.22))',\n\tborderRadius: 3,\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: '0.8em',\n\tpadding: '4px 9px',\n\tcursor: 'pointer',\n\twhiteSpace: 'nowrap',\n}\n\n/** An inline text button inside a muted line. */\nconst inlineLink: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: 'none',\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: 'inherit',\n\tpadding: 0,\n\ttextDecoration: 'underline',\n\ttextUnderlineOffset: 2,\n\tcursor: 'pointer',\n}\n\n/** First cell layout: content, with the exclude control pushed to its right. */\nconst firstCell: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'center',\n\tjustifyContent: 'space-between',\n\tgap: 8,\n}\n\n/** The per-row exclude control. Quiet until focused or hovered. */\nconst excludeButton: React.CSSProperties = {\n\tappearance: 'none',\n\tbackground: 'transparent',\n\tborder: 'none',\n\tcolor: 'inherit',\n\tfont: 'inherit',\n\tfontSize: '1.05em',\n\tlineHeight: 1,\n\topacity: 0.35,\n\tpadding: '0 2px',\n\tcursor: 'pointer',\n\tflex: '0 0 auto',\n}\n\n/** Table scrolls inside its own container, so the panel never scrolls sideways. */\nconst tableWrapper: React.CSSProperties = { overflowX: 'auto', width: '100%' }\n\n/** Base table geometry. */\nconst tableBase: React.CSSProperties = { width: '100%', borderCollapse: 'collapse', minWidth: 420 }\n\n/** Header cell: sticky-feeling separation from the body without a heavy rule. */\nconst headCell: React.CSSProperties = {\n\tpadding: 0,\n\ttextAlign: 'left',\n\tborderBottom: '1px solid var(--card-border-color, rgba(128,128,128,0.22))',\n\twhiteSpace: 'nowrap',\n}\n\nconst headCellNumeric: React.CSSProperties = { ...headCell, textAlign: 'right' }\n\nconst bodyCell: React.CSSProperties = {\n\tpadding: '8px 12px',\n\ttextAlign: 'left',\n\tfontWeight: 400,\n\tborderBottom: '1px solid var(--card-border-color, rgba(128,128,128,0.18))',\n}\n\nconst bodyCellNumeric: React.CSSProperties = {\n\t...bodyCell,\n\ttextAlign: 'right',\n\tfontVariantNumeric: 'tabular-nums',\n}\n\n/** The whole header cell is the control, so the hit area matches what looks clickable. */\nfunction sortButton(numeric: boolean): React.CSSProperties {\n\treturn {\n\t\tappearance: 'none',\n\t\tbackground: 'transparent',\n\t\tborder: 'none',\n\t\tcolor: 'inherit',\n\t\tfont: 'inherit',\n\t\tfontSize: '0.78em',\n\t\tletterSpacing: '0.06em',\n\t\ttextTransform: 'uppercase',\n\t\topacity: 0.7,\n\t\tpadding: '8px 12px',\n\t\twidth: '100%',\n\t\tdisplay: 'flex',\n\t\tgap: 6,\n\t\talignItems: 'center',\n\t\tjustifyContent: numeric ? 'flex-end' : 'flex-start',\n\t\tcursor: 'pointer',\n\t}\n}\n\n/** Sort indicator. */\nconst sortMark: React.CSSProperties = { opacity: 0.7, fontSize: '0.9em' }\n\n/** Present to screen readers, absent visually — the caption names the table without repeating the heading. */\nexport const visuallyHidden: React.CSSProperties = {\n\tposition: 'absolute',\n\twidth: 1,\n\theight: 1,\n\toverflow: 'hidden',\n\tclip: 'rect(0 0 0 0)',\n\twhiteSpace: 'nowrap',\n}\n","/**\n * The series palette, and the rules that keep it honest.\n *\n * Every chart in this package was drawn in `currentColor` at varying alpha, so five series on one\n * chart were five greys separated by dash pattern alone. That is legible in a screenshot and not\n * in use: a reader tracking co-movement between GA4 and Vercel had to keep which-line-is-which in\n * their head while looking at the shape.\n *\n * Three constraints decided these values, and all three are enforced by tests rather than asserted\n * here:\n *\n * 1. ONE SET FOR BOTH THEMES. The Studio ships light and dark and this component is not told which\n * it is in. Rather than swap palettes at a breakpoint we cannot observe, every colour clears\n * 3:1 — the WCAG floor for a graphical object — against BOTH a white card and Sanity's dark\n * card. That is what pins them to mid-lightness.\n *\n * 2. HUE FAMILY MEANS SOURCE. The two GA4 series are deliberately neighbours in the warm range,\n * because they are the same instrument measuring two things; Vercel, orders and revenue each\n * get their own family. So the palette carries a fact rather than just distinguishing rows.\n *\n * 3. COLOUR IS NEVER THE ONLY CHANNEL. Completeness stays encoded as a dash pattern and every\n * value stays readable in the tooltip. The two GA4 hues are close enough to converge for a\n * deuteranope — which is acceptable precisely because dash and label still separate them, and\n * unacceptable for any pair that does not have that second channel.\n */\n\n/** One series colour. The key names what it measures, not what it looks like. */\nexport type SeriesKey = 'vercel' | 'ga4Pageviews' | 'ga4Sessions' | 'orders' | 'revenue'\n\n/**\n * The series colours.\n *\n * Mid-lightness by necessity, not taste — see constraint 1 above. Changing any value here without\n * re-running `palette.test.ts` will silently break a theme nobody is looking at.\n */\nexport const SERIES: Record<SeriesKey, string> = {\n\t/** The complete, server-side count. The reference every other line is read against. */\n\tvercel: '#4C8FD0',\n\t/** GA4's pageviews — the like-for-like counterpart to Vercel's. */\n\tga4Pageviews: '#DD6B3F',\n\t/** GA4's sessions. Warm, like its sibling above: same instrument, different unit. */\n\tga4Sessions: '#BC861F',\n\t/** Orders, from the foundry's own records. Exact. */\n\torders: '#2E9E6B',\n\t/** Revenue, same source, different unit. */\n\trevenue: '#BF5D9B',\n}\n\n/**\n * Alpha for a filled region drawn in a series colour.\n *\n * Regions sit under lines and behind labels, so they are held well below the stroke's weight. The\n * shortfall region is the one exception the panel makes, and it says so where it draws it.\n */\nexport const REGION_ALPHA = 0.18\n\n/**\n * A series colour at a given alpha, as an `rgba()` string.\n *\n * SVG `fill-opacity` would be simpler, but it multiplies with any opacity already on the parent\n * group and these fills sit inside groups that carry their own. Baking the alpha into the colour\n * keeps a region's weight the same wherever it is mounted.\n *\n * @param key - which series\n * @param alpha - 0 to 1\n */\nexport function seriesFill(key: SeriesKey, alpha: number = REGION_ALPHA): string {\n\treturn withAlpha(SERIES[key], alpha)\n}\n\n/**\n * Any hex at a given alpha, as an `rgba()` string.\n *\n * @param hex - the colour\n * @param alpha - 0 to 1\n */\nexport function withAlpha(hex: string, alpha: number): string {\n\tconst r = parseInt(hex.slice(1, 3), 16)\n\tconst g = parseInt(hex.slice(3, 5), 16)\n\tconst b = parseInt(hex.slice(5, 7), 16)\n\treturn `rgba(${r}, ${g}, ${b}, ${alpha})`\n}\n\n/**\n * The comparison identity: every figure, mark and control that refers to the OTHER window.\n *\n * Deliberately outside `SERIES`, because it names a RELATIONSHIP rather than a source. A delta, the\n * ghost line behind a series, and the basis toggle that chooses between \"previous period\" and \"last\n * year\" are one idea, and before this they were three greys at three opacities plus an underline on\n * one tone — so nothing on screen said they were related, which is exactly what a reader asked for.\n *\n * Hue 255 sits in the widest gap in the series ring (vercel 210 to revenue 322). Its nearest\n * neighbour under deuteranopia is vercel at dE 12.2 — wider than the ga4Pageviews/ga4Sessions pair\n * this palette already ships on purpose, and 57 from the neutral grey it replaces, which is what\n * lets it read as an identity rather than as more muting.\n *\n * 4.29:1 on white and 4.28:1 on Sanity's dark card. For this pair of grounds 4.285 is the ceiling\n * any single colour can reach on both sides at once, so this is the balance point, not a\n * compromise. It only holds at FULL ALPHA: at 0.9 it is 3.6, at 0.7 it is 2.6, at 0.55 it is 2.1.\n * That is why the opacity ladder that used to carry emphasis had to go rather than be tinted.\n */\nexport const COMPARISON = '#8368D4'\n\n/**\n * The comparison colour as TEXT, which needs a higher floor than a mark does.\n *\n * `COMPARISON` is balanced to sit as well as possible on both grounds at once, and 4.285:1 is the\n * arithmetic ceiling for that: make it lighter and the white card fails, darker and the dark card\n * does. That clears the 3:1 a graphical object needs, and it is what every comparison MARK uses.\n *\n * But the identity is also worn by the deltas, their baselines, the toggle's label and the sentence\n * naming the window — all of them small text, which WCAG 1.4.3 holds to 4.5:1. No single colour can\n * reach that on both grounds, so text gets a pair, one per ground, and the browser picks.\n *\n * The pair is selected off Sanity's own `data-scheme` attribute. An earlier version used\n * `light-dark()` on `:root` — which measured correctly in isolation and was wrong in the Studio,\n * because Sanity sets `color-scheme` on the CARD, not on the root. At `:root` the used scheme is\n * `normal`, `light-dark()` resolves to its light half, and a dark Studio would have rendered the\n * light colour at 3.09:1 — worse than the balanced colour it replaced. Verified in the browser\n * rather than reasoned about: the outermost Card carries `data-scheme=\"dark\"` with background\n * #13141b, and `data-scheme=\"light\"` with #ffffff, which are exactly the two grounds this module\n * measures against.\n *\n * Every use site keeps the balanced colour as its `var()` fallback, so if that attribute ever\n * changes the identity degrades to where it started rather than disappearing.\n */\nexport const COMPARISON_TEXT = `var(--vi-comparison, ${COMPARISON})`\n\n/** The text pair. Same hue family as COMPARISON, so the identity survives the theme switch. */\nexport const COMPARISON_ON_LIGHT = '#6A4FC4'\nexport const COMPARISON_ON_DARK = '#A594E8'\n\n/**\n * The stylesheet that defines `--vi-comparison`, rendered once by the tool.\n *\n * A custom property rather than an inline style because an inline style cannot carry a fallback:\n * React sets one value per key, so there would be no way to say \"this colour, or that one if the\n * ground is dark\" without knowing the theme at render time, which this package deliberately does\n * not.\n */\nexport const COMPARISON_STYLE = `\n:root { --vi-comparison: ${COMPARISON}; }\n[data-scheme=\"light\"] { --vi-comparison: ${COMPARISON_ON_LIGHT}; }\n[data-scheme=\"dark\"] { --vi-comparison: ${COMPARISON_ON_DARK}; }\n`.trim()\n\n/** The grounds a colour has to work on: Sanity's light card and its dark one. */\nexport const GROUNDS = { light: '#ffffff', dark: '#13141b' } as const\n\n/** Relative luminance, per WCAG 2.1. */\nexport function luminance(hex: string): number {\n\tconst channel = (raw: number): number => {\n\t\tconst c = raw / 255\n\t\treturn c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4\n\t}\n\tconst r = channel(parseInt(hex.slice(1, 3), 16))\n\tconst g = channel(parseInt(hex.slice(3, 5), 16))\n\tconst b = channel(parseInt(hex.slice(5, 7), 16))\n\treturn 0.2126 * r + 0.7152 * g + 0.0722 * b\n}\n\n/** Contrast ratio between two colours, per WCAG 2.1. Symmetric, 1 to 21. */\nexport function contrast(a: string, b: string): number {\n\tconst la = luminance(a)\n\tconst lb = luminance(b)\n\treturn (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)\n}\n\n// ---------------------------------------------------------------------------\n// The mark registry\n// ---------------------------------------------------------------------------\n\n/**\n * What a mark is for, which decides whether 3:1 applies to it.\n *\n * `data` — carries a value the reader must perceive: a line, a bar, a stem. WCAG 1.4.11 applies.\n * `fill` — a region tinted behind or beneath data. Deliberately light so what it covers stays\n * readable, so it CANNOT carry 3:1 and must name a `boundary` mark that does.\n * `furniture` — rails, grids, baselines. Not a value; exempt, but still drawn visibly.\n */\nexport type MarkRole = 'data' | 'fill' | 'furniture'\n\n/** One drawn mark: the colour, the alpha it is ACTUALLY drawn at, and what it is for. */\nexport interface Mark {\n\tkey: SeriesKey | 'neutral' | 'comparison'\n\t/** The opacity the renderer applies. This is the number the contrast test composites with. */\n\talpha: number\n\trole: MarkRole\n\t/** For a `fill`: the `data` mark whose stroke makes this region's extent perceivable. */\n\tboundary?: string\n\t/** Why this mark exists, for the next person reading the test output. */\n\tnote: string\n}\n\n/**\n * Every mark this package draws.\n *\n * THIS IS THE POINT OF THIS MODULE. The previous version exported five hex constants, asserted 3:1\n * against them, and left each renderer free to multiply by whatever opacity it liked — so the test\n * validated a colour that was never drawn, and the shipped shortfall region sat at 1.40:1 while a\n * green suite said the palette was sound. Twenty-nine tests, mutation-checked, all measuring a\n * constant.\n *\n * Renderers read their alpha from here. The test walks this registry and composites each entry over\n * both grounds. The two cannot drift, because they are the same object.\n */\nexport const MARKS: Record<string, Mark> = {\n\t'chart.line': { key: 'vercel', alpha: 1, role: 'data', note: 'A source series in the timeline' },\n\t'chart.lossyLine': { key: 'ga4Pageviews', alpha: 1, role: 'data', note: 'The lossier source, dashed' },\n\t'chart.coverage': { key: 'ga4Sessions', alpha: 1, role: 'data', note: 'Share one source saw of another' },\n\t'chart.stem': { key: 'orders', alpha: 1, role: 'data', note: 'One day that had orders' },\n\t'chart.zeroTick': { key: 'orders', alpha: 1, role: 'data', note: 'A day MEASURED at zero — the package\\'s founding distinction' },\n\t// Renamed from `chart.regionEdge`. It was the boundary of a filled region; the region is gone —\n\t// its width was the absolute gap, which under a flat coverage rate is the traffic curve scaled\n\t// down, so it widened on busy days with no change in the instrument. This is now simply the\n\t// lossier source's own line, which is what the collapse detector and the spoken summary read.\n\t'chart.lossy': { key: 'ga4Pageviews', alpha: 1, role: 'data', note: 'The lossier source\\'s own level, dashed beneath the complete one' },\n\t'chart.ghost': { key: 'comparison', alpha: 1, role: 'data', note: 'The same window last period, behind everything' },\n\t'chart.grid': { key: 'neutral', alpha: 0.18, role: 'furniture', note: 'Grid rules' },\n\t'bar.fill': { key: 'vercel', alpha: 1, role: 'data', note: 'Every proportion, comparison and funnel bar' },\n\t'bar.track': { key: 'neutral', alpha: 0.12, role: 'furniture', note: 'The rail a bar sits in' },\n\t// Neither a source nor money. It was drawn in the REVENUE hue, so a magenta bar between funnel\n\t// rungs read as takings; GA4's hue was rejected for the opposite reason, since that hue means\n\t// \"this instrument measured it\" everywhere else. People who did not continue are an absence, so\n\t// they take the neutral — which clears 4.00:1 light and 4.59:1 dark at full alpha.\n\t'bar.lost': { key: 'neutral', alpha: 1, role: 'data', note: 'People who did not continue past a funnel rung' },\n\t'bar.seen': { key: 'ga4Pageviews', alpha: 1, role: 'data', note: 'The share of a complete count that the lossy source saw' },\n\t'estimate.dot': { key: 'ga4Pageviews', alpha: 1, role: 'data', note: 'One independent estimate of how much GA4 sees' },\n\t'estimate.interval': { key: 'ga4Pageviews', alpha: 0.35, role: 'fill', boundary: 'estimate.dot', note: 'How wide the sample leaves that estimate — the dot is its own boundary' },\n\t'estimate.rule': { key: 'neutral', alpha: 0.45, role: 'furniture', note: 'The 100% reference a capture rate is read against' },\n\t// A segment per colour rather than a dash pattern per segment. Dash was the ONLY channel\n\t// separating these lines, and it is the one channel `preserveAspectRatio=\"none\"` distorted —\n\t// so two segments could be drawn identically on a wide pane. The keys are reused from the\n\t// series palette because they already clear 3:1 on both grounds; they carry no source meaning\n\t// here, and the legend states which is which.\n\t'survival.line': { key: 'vercel', alpha: 1, role: 'data', note: 'The first audience segment falling through a funnel' },\n\t'survival.line.2': { key: 'ga4Pageviews', alpha: 1, role: 'data', note: 'The second audience segment' },\n\t'survival.line.3': { key: 'orders', alpha: 1, role: 'data', note: 'The third audience segment' },\n\t'survival.line.4': { key: 'revenue', alpha: 1, role: 'data', note: 'The fourth audience segment' },\n\t'survival.grid': { key: 'neutral', alpha: 0.18, role: 'furniture', note: 'The 50% and 100% references a survival line is read against' },\n}\n\n/**\n * The rgba a mark is drawn in.\n *\n * @param name - a key of MARKS\n */\nexport function mark(name: string): string {\n\tconst m = MARKS[name]\n\tif (!m) throw new Error(`Unknown mark: ${name}`)\n\treturn withAlpha(baseHex(m.key), m.alpha)\n}\n\n/**\n * The hex a mark key resolves to, before its alpha is applied.\n *\n * Exported so the contrast test composites the SAME colour the renderer draws. It had its own copy\n * of this mapping; the moment a third kind of key existed, that copy returned undefined.\n *\n * @param key - a series, the neutral, or the comparison identity\n */\nexport function baseHex(key: SeriesKey | 'neutral' | 'comparison'): string {\n\tif (key === 'neutral') return NEUTRAL\n\tif (key === 'comparison') return COMPARISON\n\treturn SERIES[key]\n}\n\n/** The grey used for furniture that belongs to no series. */\nexport const NEUTRAL = '#7f7f7f'\n\n/**\n * Composite a colour at an alpha over a ground, so contrast can be measured as DRAWN.\n *\n * @param hex - the mark colour\n * @param alpha - the opacity the renderer applies\n * @param ground - what it is drawn on top of\n */\nexport function composite(hex: string, alpha: number, ground: string): string {\n\tconst mix = (at: number): number => Math.round(\n\t\tparseInt(hex.slice(at, at + 2), 16) * alpha + parseInt(ground.slice(at, at + 2), 16) * (1 - alpha),\n\t)\n\tconst hexOf = (n: number) => n.toString(16).padStart(2, '0')\n\treturn `#${hexOf(mix(1))}${hexOf(mix(3))}${hexOf(mix(5))}`\n}\n","/** Shared contract types for the Studio plugin and the API-route handler it calls. */\n\n// ---------------------------------------------------------------------------\n// Metric values\n// ---------------------------------------------------------------------------\n\n/**\n * Why a metric has no usable number. Kept distinct rather than collapsed into one flag, because\n * the UI must say different things: \"this site never tracked it\" is permanent, \"not yet at this\n * date\" resolves as time passes, and \"GA4 withheld it\" means the number exists but is hidden.\n */\nexport type UnavailableReason =\n\t/** The event has never been instrumented on this site. Permanent for historical ranges. */\n\t| 'not_instrumented'\n\t/** Instrumented, but the requested range predates the cutover date. */\n\t| 'before_cutover'\n\t/** GA4 suppressed the row for privacy thresholding. A real number exists; we may not see it. */\n\t| 'suppressed'\n\t/** The event exists in the code but stopped reaching GA4 for this period. Not a real zero. */\n\t| 'outage'\n\t/** The upstream source errored or timed out. Retryable, unlike the others. */\n\t| 'source_error'\n\t/** The metric is not defined for this site at all (e.g. a flow that does not exist there). */\n\t| 'not_applicable'\n\t/**\n\t * The site's API route is older than this figure — the data exists, this deployment cannot\n\t * return it. Distinct from `not_applicable`, which says the figure never applies here.\n\t */\n\t| 'route_outdated'\n\n/**\n * A metric that may legitimately have no value.\n *\n * The whole point of this union is that there is no way to read a number without first handling\n * the absent case — a missing metric can never silently coerce to 0 and be charted as a real\n * trough. `partial` carries a number that is only valid for part of the requested range.\n */\nexport type MetricValue =\n\t| { status: 'ok'; value: number }\n\t| { status: 'partial'; value: number; coveredFrom: string; note: string }\n\t/**\n\t * A figure inferred rather than counted — a GA4 count grossed up by its measured capture rate.\n\t *\n\t * Carries an interval, not just a point, because the capture rate is itself estimated from\n\t * several ratios that rarely agree exactly. Its own variant so it can never be read as a\n\t * measurement: the union forces every render site to handle it, which is the same reason\n\t * `unavailable` is a variant rather than a null.\n\t */\n\t| { status: 'estimated'; value: number; low: number; high: number; basis: string }\n\t| { status: 'unavailable'; reason: UnavailableReason; detail?: string }\n\n/**\n * Construct an inferred metric.\n *\n * @param value - the point estimate\n * @param low - the low end of the plausible range\n * @param high - the high end\n * @param basis - how it was inferred, in words the panel shows directly\n */\nexport function estimated(value: number, low: number, high: number, basis: string): MetricValue {\n\treturn { status: 'estimated', value, low, high, basis }\n}\n\n/** Construct an available metric. */\nexport function ok(value: number): MetricValue {\n\treturn { status: 'ok', value }\n}\n\n/** Construct an unavailable metric. */\nexport function unavailable(reason: UnavailableReason, detail?: string): MetricValue {\n\treturn { status: 'unavailable', reason, detail }\n}\n\n/**\n * Construct a metric covering only part of the requested range.\n *\n * @param value - the number for the covered portion\n * @param coveredFrom - ISO date from which the number is valid\n * @param note - short human-readable reason, shown next to the figure\n */\nexport function partial(value: number, coveredFrom: string, note: string): MetricValue {\n\treturn { status: 'partial', value, coveredFrom, note }\n}\n\n/**\n * Read a metric's number, or `null` when there isn't a usable one.\n * Use at render time so the absent case has to be handled explicitly.\n */\nexport function valueOrNull(metric: MetricValue | undefined): number | null {\n\t// Undefined is treated as absent, not as a type error to crash on. A site's API route can be\n\t// older than the Studio bundle reading it — the package documents that as its normal state\n\t// between a publish and a redeploy — so every field is optional in practice whatever the type\n\t// says, and a helper that throws on one takes the whole panel down.\n\treturn !metric || metric.status === 'unavailable' ? null : metric.value\n}\n\n// ---------------------------------------------------------------------------\n// Date ranges\n// ---------------------------------------------------------------------------\n\n/** The range granularities offered in the UI. */\n/**\n * A named range, or `custom` for an explicit start and end supplied by the caller.\n *\n * The named ones are trailing windows ending today, not calendar periods — `quarter` is the last\n * 91 days, not Q3. `custom` exists so a reader can look at a past period at all, which none of the\n * trailing windows can express.\n */\nexport type RangeKey = 'week' | 'month' | 'quarter' | 'year' | 'custom'\n\n/**\n * A resolved date range. Both bounds are inclusive ISO `YYYY-MM-DD` dates in `timezone`.\n *\n * `timezone` is carried explicitly because GA4 buckets by the property's configured timezone,\n * Vercel reports in UTC, and Sanity `_createdAt` is UTC — comparing them without a single declared\n * anchor silently shifts events across day and week boundaries.\n */\nexport interface DateRange {\n\tkey: RangeKey\n\tstart: string\n\tend: string\n\ttimezone: string\n}\n\n// ---------------------------------------------------------------------------\n// Reports\n// ---------------------------------------------------------------------------\n\n/** The report names this package serves. Used as a strict allow-list, never as dynamic dispatch. */\nexport const REPORT_NAMES = [\n\t'measurement-health',\n\t'acquisition',\n\t'journey',\n\t'typeface-interest',\n\t'diagnostics',\n] as const\n\nexport type ReportName = (typeof REPORT_NAMES)[number]\n\n/** Narrow an untrusted string to a known report name. */\nexport function isReportName(value: unknown): value is ReportName {\n\treturn typeof value === 'string' && (REPORT_NAMES as readonly string[]).includes(value)\n}\n\n/** Which upstreams a report consulted, and how each fared. */\nexport type SourceStatus =\n\t| { status: 'ok' }\n\t| { status: 'unconfigured' }\n\t| { status: 'error'; message: string }\n\n/** Upstream data sources this package can read. */\nexport type SourceName = 'ga4' | 'vercel' | 'sanity' | 'mailchimp'\n\n/**\n * Envelope around every report response.\n *\n * `sources` is mandatory rather than optional because partial failure is the normal case here,\n * not the exception: a panel comparing three sources must be able to distinguish \"this source\n * reported zero\" from \"this source never answered\", which a bare payload cannot express.\n */\nexport interface ReportEnvelope<T> {\n\treport: ReportName\n\trange: DateRange\n\t/** Per-source outcome. A report may succeed overall while one source is unconfigured. */\n\tsources: Partial<Record<SourceName, SourceStatus>>\n\t/** Caveats to surface in the UI, e.g. GA4 sampling or an instrumentation cutover in range. */\n\tnotices: string[]\n\tdata: T\n\t/**\n\t * The same report over the immediately preceding equivalent window.\n\t *\n\t * A bare level is close to meaningless at these volumes: \"389 sessions\" is neither good nor bad\n\t * without last week beside it, and the 24 August collapse would have announced itself on every\n\t * panel as a delta while going unnoticed for over a week as a level. Absent when the comparison\n\t * could not be computed, because a missing comparison must not read as \"no change\". Absent\n\t * means the prior run threw, or this report does not draw deltas; there is deliberately no\n\t * cutover check here, and an earlier version of this comment claimed one existed.\n\t */\n\tcomparison?: {\n\t\trange: DateRange\n\t\tdata: T\n\t\t/**\n\t\t * True when the CURRENT window still has unprocessed days while the comparison window is\n\t\t * complete, which biases every GA4-derived delta downwards.\n\t\t */\n\t\tprovisional: boolean\n\t\t/** Which baseline the reader chose: the preceding window, or the same window a year back. */\n\t\tbasis?: 'previous-period' | 'same-period-last-year'\n\t}\n}\n\n/** Error shape returned by the handler. Never leaks upstream error text to the browser. */\nexport interface ReportError {\n\terror: string\n\treport?: string\n}\n","/**\n * The report panels.\n *\n * Every panel renders a table or labelled bars rather than a chart, and every one surfaces its own\n * caveats inline. Tables are the accessible representation as well as the visual one, so there is\n * no separate \"view as data\" toggle that could drift out of sync with what is displayed.\n *\n * Every array these panels read is accessed defensively, and that is not paranoia about the server.\n * The Studio bundle and the site's API route are separate deployments on separate schedules: a\n * Studio can be upgraded while its route still runs an older package, and then a field added in the\n * newer version simply is not in the response. On 2026-09-01 a Studio on 0.8.0 read `data.daily`\n * from a route still on 0.6.2 and took the whole tool down with \"Cannot read properties of\n * undefined\". A panel must degrade to showing less, never to a stack trace.\n */\n\nimport React from 'react'\nimport { Badge, Card, Flex, Heading, Label, Stack, Text } from '@liiift-studio/sanity-ui-compat'\nimport { ChartData, ContainmentBar, Delta, SPACE, panelStack, stackBlock, stackGroup, formatDay, LicenceLadder, EstimateDotPlot, RatioFigure, FunnelChart, MetricFigure, NoticeList, MIN_DELTA_BASE, MIN_RATE_DENOMINATOR, ProportionChart, Section, SectionTitle, isContainment, SortableTable, formatCount, formatMoney, formatPercent, splitGrid } from './Figure'\nimport { CrossSourceTimeline } from './CrossSourceTimeline'\nimport { SEND_WINDOW_DAYS } from '../core/ranges'\nimport { describeRank, weeklyRank } from '../core/rank'\nimport { samplingInterval } from '../core/capture'\nimport type {\n\tAcquisitionData,\n\tCheckStatus,\n\tSourceRow,\n\tTypefaceInterestRow,\n\tDiagnosticReport,\n\tCrossSourceDay,\n\tEmailCampaign,\n\tJourneyData,\n\tJourneySegment,\n\tJourneyStep,\n\tLandingPage,\n\tMeasurementHealthData,\n\tTypefaceInterestData,\n} from '../reportData'\nimport { ok, partial, unavailable, type MetricValue } from '../types'\n\n/**\n * Sortable value for a metric, or null when there is nothing to sort on.\n *\n * An unavailable metric is not a zero — sorting it as one would put \"never measured\" at the bottom\n * of an ascending column beside genuine zeros, which is the confusion MetricValue exists to prevent.\n * SortableTable sinks nulls in both directions instead.\n */\nfunction metricSortValue(metric: MetricValue | undefined): number | null {\n\tif (!metric || metric.status === 'unavailable') return null\n\treturn Number.isFinite(metric.value) ? metric.value : null\n}\n\n/**\n * Read a metric field that may not exist in the response at all.\n *\n * The Studio bundle and the site's API route deploy on separate rails, so a Studio can be several\n * versions ahead of the route it calls — Darden's Studio was on 0.13.1 while its production route\n * still resolved 0.6.x. Every field added since then arrives as `undefined`, and reading `.status`\n * on it threw inside a sort comparator, which meant the panel rendered nothing at all rather than\n * rendering without one column.\n *\n * Array fields were already guarded with `?? []`. Metric fields were not, and this is the guard\n * they needed.\n *\n * @param metric - the possibly-absent field\n * @param detail - what to say when it is absent\n */\nfunction metricOr(metric: MetricValue | undefined, detail: string): MetricValue {\n\tif (metric) return metric\n\treturn { status: 'unavailable', reason: 'route_outdated', detail }\n}\n\n/** What an absent field means: the route predates the field, not the site lacking the data. */\n// Phrased for the reader, who does not deploy anything. The action belongs to whoever maintains\n// the site, and naming it as theirs rather than issuing it as an instruction is the difference\n// between information and a task nobody in the room can do.\nconst OLDER_ROUTE = 'This site\\u2019s analytics route is older than this figure — it will appear after the site is next deployed.'\n\n\n\n\n/**\n * A usable number, or null.\n *\n * Narrower than a `!== null` check, which lets `undefined` through — and `undefined` is exactly\n * what an older API route sends for a field added since it was deployed. `formatPercent(undefined)`\n * then renders \"NaN%\" in the largest type on the panel.\n */\nfunction finiteOrNull(value: number | null | undefined): number | null {\n\treturn typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/** Identity for a source row: every dimension the report groups by. */\nfunction acquisitionRowKey(row: SourceRow): string {\n\treturn [row.source, row.channel, row.medium ?? '', row.campaign ?? ''].join(' \\u203a ')\n}\n\n/** A figure with its period-over-period delta alongside, wrapping on a narrow pane. */\nconst figureRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\talignItems: 'baseline',\n\tgap: SPACE.block,\n\tflexWrap: 'wrap',\n}\n\n/** Referrer links, marked as links without shouting. */\n\n\n\nconst sourceLink: React.CSSProperties = {\n\tcolor: 'inherit',\n\ttextDecoration: 'underline',\n\ttextUnderlineOffset: 3,\n\ttextDecorationThickness: 1,\n}\n\n/**\n * Whether a GA4 source value is a real host worth linking.\n *\n * GA4's own buckets — (direct), (not set), (none) — look like sources in the table and are not\n * destinations. Linking them would produce a dead https://(direct) that erodes trust in every other\n * link on the page.\n */\nfunction isLinkableHost(source: string): boolean {\n\treturn /^[a-z0-9-]+(\\.[a-z0-9-]+)+$/i.test(source)\n}\n\n/**\n * Card row that reflows by available width rather than by viewport breakpoints.\n *\n * The Studio panel is a resizable pane inside a Studio inside, sometimes, an iframe — its width has\n * little to do with the viewport, so `columns={[1, 3]}` was answering the wrong question. auto-fit\n * with a minimum also survives the compat shim falling back to a plain div, where a column token\n * would mean nothing.\n */\nconst cardGrid: React.CSSProperties = {\n\tdisplay: 'grid',\n\tgridTemplateColumns: 'repeat(auto-fit, minmax(min(14rem, 100%), 1fr))',\n\tgap: SPACE.group,\n}\n\n/**\n * How many pageviews GA4 did not see on a day, or null when either side is unmeasured.\n *\n * Null rather than zero: a day one source did not report is not a day they agreed. Negative is\n * kept, not clamped — GA4 counting MORE than Vercel is a real and diagnosable state (a tag firing\n * twice), and hiding it behind a floor of zero would make the two failure modes look identical.\n *\n * @param day - one day of the cross-source series\n */\nexport function gapOf(day: { vercelPageviews: number | null; ga4Pageviews: number | null }): number | null {\n\tif (day.vercelPageviews === null || day.ga4Pageviews === null) return null\n\treturn day.vercelPageviews - day.ga4Pageviews\n}\n\n/**\n * The fraction of a day's pageviews GA4 saw, or null when it cannot be computed.\n *\n * Null on a zero denominator as well as on a missing measurement: no traffic means no coverage\n * figure, and 0/0 rendered as 0% would put the quietest days at the top of a sort meant to find\n * the worst ones.\n *\n * @param day - one day of the cross-source series\n */\nexport function coverageOf(day: { vercelPageviews: number | null; ga4Pageviews: number | null }): number | null {\n\tif (day.vercelPageviews === null || day.ga4Pageviews === null || day.vercelPageviews <= 0) return null\n\treturn day.ga4Pageviews / day.vercelPageviews\n}\n\n/**\n * Overview — the five-minute read.\n *\n * Revenue, orders, the mailing list, the cross-source timeline and the campaign table all used to\n * live on a tab called \"Measurement health\", whose own blurb announced it was about how much of\n * reality each source sees. Four of the five questions a foundry owner opens this for were filed\n * under plumbing, on tab four, behind a door labelled for the instrument. This is the same data,\n * first, under a heading that says what it is.\n */\nexport function OverviewPanel({ data, previous, onBrush }: {\n\tdata: MeasurementHealthData\n\tprevious?: MeasurementHealthData\n\t/** Narrow every panel to a span dragged on the timeline. */\n\tonBrush?: (start: string, end: string) => void\n}): React.ReactElement {\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t<Verdict data={data} previous={previous} />\n\n\t\t\t<div style={cardGrid}>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Revenue</Label>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t{/* One render path, told what the number is. The card used to take the currency\n\t\t\t\t\t\t\t branch only on `ok` and fall through to MetricFigure — and therefore to the\n\t\t\t\t\t\t\t COUNT formatter — for everything else. Revenue here is permanently partial,\n\t\t\t\t\t\t\t because most orders predate the amount field, so the branch never fired and\n\t\t\t\t\t\t\t the tab's headline money rendered as a bare \"12,346\". */}\n\t\t\t\t\t\t\t<MetricFigure\n\t\t\t\t\t\t\t\tmetric={metricOr(data.revenue, OLDER_ROUTE)}\n\t\t\t\t\t\t\t\tlabel=\"Revenue\"\n\t\t\t\t\t\t\t\tunit=\"money\"\n\t\t\t\t\t\t\t\tcurrency={data.currency ?? null}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Delta current={metricSortValue(data.revenue)} previous={metricSortValue(previous?.revenue)} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/* The rank, beside the change rather than instead of it.\n\t\t\t\t\t\t\n\t\t\t\t\t\t A percentage on these counts is the part that misleads — seven orders\n\t\t\t\t\t\t against four is \"+75%\" and moves twenty-five points on one more sale —\n\t\t\t\t\t\t but the change is still what a reader looks for first, so it stays. The\n\t\t\t\t\t\t rank is what tells them whether the week was normal, which the percentage\n\t\t\t\t\t\t never could. Exact quantities only: ranking a GA4 figure would rank the\n\t\t\t\t\t\t instrument's mood alongside the business. */}\n\t\t\t\t\t\t{/* Gated on the SAME condition as the timeline's revenue row, which is the point.\n\t\t\t\t\t\t\n\t\t\t\t\t\t `crossSource[].revenue` is `revenueByDate[date] ?? 0`, and a day whose orders\n\t\t\t\t\t\t all lacked an amount has no key — so it is summed as a zero. The chart below\n\t\t\t\t\t\t refuses to draw that series for exactly this reason; the rank line beside it\n\t\t\t\t\t\t ranked weeks off the same numbers and announced \"best of the last 13 weeks\".\n\t\t\t\t\t\t One chart declining to show a series while a sentence four inches up draws a\n\t\t\t\t\t\t conclusion from it is the disagreement this whole review kept finding. */}\n\t\t\t\t\t\t{revenueCoversEveryOrder(data) && rankLine(data.crossSource, (d) => d.revenue) && (\n\t\t\t\t\t\t\t<Text size={0} muted>{rankLine(data.crossSource, (d) => d.revenue)}</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Orders</Label>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.orders, OLDER_ROUTE)} label=\"Orders\" />\n\t\t\t\t\t\t\t<Delta current={metricSortValue(data.orders)} previous={metricSortValue(previous?.orders)} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{rankLine(data.crossSource, (d) => d.orders) && (\n\t\t\t\t\t\t\t<Text size={0} muted>{rankLine(data.crossSource, (d) => d.orders)}</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Traffic</Label>\n\t\t\t\t\t\t{/* The verdict asserts a traffic move; without this card the reader was told a\n\t\t\t\t\t\t number changed and shown no number. Vercel's, because it is the complete\n\t\t\t\t\t\t one — GA4's sessions differ from it by 86% on Darden. */}\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.vercelPageviews, OLDER_ROUTE)} label=\"Pageviews\" />\n\t\t\t\t\t\t\t<Delta current={metricSortValue(data.vercelPageviews)} previous={metricSortValue(previous?.vercelPageviews)} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{data.vercelPageviews && data.vercelPageviews.status !== 'unavailable' && (\n\t\t\t\t\t\t\t<Text size={0} muted>Pageviews, from Vercel’s own counter.</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t{/* \"Total\", in the label, because this card sits in a row with Revenue, Orders and\n\t\t\t\t\t\t Traffic — all scoped to the selected range — while it alone is Mailchimp's\n\t\t\t\t\t\t current all-time count. On a Week range the growth line beneath is withheld\n\t\t\t\t\t\t (Mailchimp reports growth by calendar month), so the card was a lifetime\n\t\t\t\t\t\t figure standing unlabelled beside three period figures. */}\n\t\t\t\t\t\t<Label size={1} muted>Mailing list, total</Label>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.audience, OLDER_ROUTE)} label=\"Mailing list members\" />\n\t\t\t\t\t\t\t{/* audienceGrowth, not a delta between windows. `member_count` is Mailchimp's\n\t\t\t\t\t\t\t CURRENT total and carries no date filter, so it is identical in this\n\t\t\t\t\t\t\t envelope and the comparison one — the delta was structurally always\n\t\t\t\t\t\t\t \"no change\", teaching the reader their list never moves, while the real\n\t\t\t\t\t\t\t net change was computed server-side and rendered nowhere. */}\n\t\t\t\t\t\t\t{(() => {\n\t\t\t\t\t\t\t\t// Rendered directly. audienceGrowth is ALREADY the net change, so passing\n\t\t\t\t\t\t\t\t// it to Delta with a baseline of 0 printed \"new from 0\" on an established\n\t\t\t\t\t\t\t\t// list of four thousand people.\n\t\t\t\t\t\t\t\tconst growth = metricSortValue(data.audienceGrowth)\n\t\t\t\t\t\t\t\tif (growth === null) {\n\t\t\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t\t\t * Absent SAYS why, rather than rendering nothing.\n\t\t\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t\t\t * `metricSortValue` returns null for any unavailable metric, so the reason\n\t\t\t\t\t\t\t\t\t * the server took care to write — \"Mailchimp reports list growth by\n\t\t\t\t\t\t\t\t\t * calendar month, so this range has no start figure\" — was discarded. And\n\t\t\t\t\t\t\t\t\t * the start figure is only fetched when the range begins on the first of a\n\t\t\t\t\t\t\t\t\t * month, which no preset range does. So on every range the card showed a\n\t\t\t\t\t\t\t\t\t * bare total and nothing else, forever, and a foundry whose largest asset\n\t\t\t\t\t\t\t\t\t * is its list would conclude the tool does not do growth.\n\t\t\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t\t\t * This is the one card that bypasses MetricFigure, which is exactly where\n\t\t\t\t\t\t\t\t\t * the absent-versus-zero rule stopped being applied.\n\t\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\t\tconst reason = data.audienceGrowth?.status === 'unavailable'\n\t\t\t\t\t\t\t\t\t\t? data.audienceGrowth.detail\n\t\t\t\t\t\t\t\t\t\t: null\n\t\t\t\t\t\t\t\t\treturn reason ? <Text size={0} muted>{reason}</Text> : null\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\t\t\t\t\t{growth > 0 ? '+' : ''}{formatCount(growth)} this period\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t})()}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{data.audience && data.audience.status !== 'unavailable' && (\n\t\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t\tEveryone subscribed today, not just this period. Mailchimp’s own count, so it is\n\t\t\t\t\t\t\t\tnot consent-gated or blockable.\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t</div>\n\n\t\t\t{/*\n\t\t\t * The two ratios a foundry would quote, which the tool held both halves of and never\n\t\t\t * divided. Revenue and Orders sat in adjacent cards; sessions and orders sat on adjacent\n\t\t\t * tabs. Derived here, so neither costs a query.\n\t\t\t */}\n\t\t\t<div style={cardGrid}>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Average order</Label>\n\t\t\t\t\t\t{/* The same treatment as visitors per order: the denominator is the whole\n\t\t\t\t\t\t story here, since at Darden only 2 of 7 orders carry an amount. */}\n\t\t\t\t\t\t<RatioFigure\n\t\t\t\t\t\t\tvalue={metricSortValue(averageOrderValue(data))}\n\t\t\t\t\t\t\tformat={(v) => formatMoney(v, data.currency ?? null)}\n\t\t\t\t\t\t\tparts={[\n\t\t\t\t\t\t\t\t{ label: 'taken', value: metricSortValue(data.revenue), format: (v) => formatMoney(v, data.currency ?? null) },\n\t\t\t\t\t\t\t\t{ label: 'orders with an amount', value: finiteOrNull(data.ordersWithTotal) },\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\tunavailable={<MetricFigure metric={averageOrderValue(data)} label=\"Average order value\" />}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{averageOrderValue(data).status === 'partial' && (averageOrderValue(data) as { note?: string }).note && (\n\t\t\t\t\t\t\t<Text size={0} muted>{(averageOrderValue(data) as { note?: string }).note}</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{averageOrderValue(data).status === 'ok' && (\n\t\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t\t{/* Only when it IS exact. Gated on `!== 'unavailable'` this rendered directly\n\t\t\t\t\t\t\t\t beneath \"Averaged over the 2 of 7 orders that carry an amount\", so the card\n\t\t\t\t\t\t\t\t stated a caveat and then denied it, eight words apart. */}\n\t\t\t\t\t\t\t\tFrom your orders, so exact.\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Visitors per order</Label>\n\t\t\t\t\t\t{/* Turn it over to see what it is made of.\n\t\t\t\t\t\t\n\t\t\t\t\t\t At seven orders a quarter this is the least trustworthy figure on the tab\n\t\t\t\t\t\t and among the most quotable — one more sale moves 337 to 295. The package\n\t\t\t\t\t\t withholds a rate below its floor everywhere else, which is right for a rate\n\t\t\t\t\t\t nobody asked for and wrong for one they did, because it leaves them nothing.\n\t\t\t\t\t\t Showing the counts on the other face lets a reader learn how thin the\n\t\t\t\t\t\t arithmetic is for themselves, which lands harder than a caveat they skim. */}\n\t\t\t\t\t\t<RatioFigure\n\t\t\t\t\t\t\tvalue={metricSortValue(visitorsPerOrder(data))}\n\t\t\t\t\t\t\tformat={(v) => formatCount(v)}\n\t\t\t\t\t\t\tparts={[\n\t\t\t\t\t\t\t\t{ label: 'visitors', value: metricSortValue(data.vercelVisitors) },\n\t\t\t\t\t\t\t\t{ label: 'orders', value: metricSortValue(data.orders) },\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\tunavailable={<MetricFigure metric={visitorsPerOrder(data)} label=\"Visitors per order\" />}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{data.vercelVisitors && data.vercelVisitors.status !== 'unavailable' && (\n\t\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t\tVercel’s visitor count. A ceiling — some are bots or the same person twice.\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t</div>\n\n\t\t\t{(data.crossSource?.length ?? 0) >= 3 && (\n\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t<SectionTitle title=\"Everything, on one time axis\" />\n\t\t\t\t\t{/* Forty words defending a design decision to a reader who never proposed the\n\t\t\t\t\t alternative had escaped from a code comment into the UI. What the reader needs\n\t\t\t\t\t is how to read the chart, not why it was drawn this way. */}\n\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\tEach row has its own scale, so the rows cannot be compared by height — only by\n\t\t\t\t\t\tshape.\n\t\t\t\t\t</Text>\n\t\t\t\t\t<CrossSourceTimeline\n\t\t\t\t\t\tonBrush={onBrush}\n\t\t\t\t\t\tcurrency={data.currency ?? null}\n\t\t\t\t\t\tmarkers={data.timelineEvents ?? []}\n\t\t\t\t\t\tseries={[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// One traffic row, not two. The line is what happened; GA4's view of it\n\t\t\t\t\t\t\t\t// is the shaded area beneath. Two peer lines made the reader reconcile\n\t\t\t\t\t\t\t\t// before getting an answer, and GA4 is not a competing estimate of\n\t\t\t\t\t\t\t\t// pageviews — it is a lossy subset of them.\n\t\t\t\t\t\t\t\tkey: 'traffic',\n\t\t\t\t\t\t\t\tlabel: 'Pageviews',\n\t\t\t\t\t\t\t\tsource: 'Vercel',\n\t\t\t\t\t\t\t\tcomplete: true,\n\t\t\t\t\t\t\t\tunit: 'count',\n\t\t\t\t\t\t\t\tpoints: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.vercelPageviews })),\n\t\t\t\t\t\t\t\t// The same row last period, so the reader can see whether this shape is normal.\n\t\t\t\t\t\t\t\tcomparison: previous?.crossSource?.length\n\t\t\t\t\t\t\t\t\t? previous.crossSource.map((d) => ({ date: d.date, value: d.vercelPageviews }))\n\t\t\t\t\t\t\t\t\t: undefined,\n\t\t\t\t\t\t\t\tshortfall: {\n\t\t\t\t\t\t\t\t\tlabel: 'Seen by GA4',\n\t\t\t\t\t\t\t\t\tsource: 'GA4',\n\t\t\t\t\t\t\t\t\t// PAGEVIEWS, not sessions. The region encodes the difference between\n\t\t\t\t\t\t\t\t\t// these two as an area, so both sides must be the same unit — this\n\t\t\t\t\t\t\t\t\t// previously shaded sessions under a pageview line, overstating the\n\t\t\t\t\t\t\t\t\t// loss by the pageviews-per-session ratio.\n\t\t\t\t\t\t\t\t\tpoints: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.ga4Pageviews })),\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t * Coverage as its own row, on a fixed 0–100% axis.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * The shaded band under the traffic line is an ABSOLUTE quantity — Vercel\n\t\t\t\t\t\t\t * minus GA4 — so under a flat 20% coverage it is 0.8 times the traffic curve\n\t\t\t\t\t\t\t * and tracks it exactly. Every busy day therefore shows a wider alarm than\n\t\t\t\t\t\t\t * every quiet day with no change whatever in the instrument, and the one\n\t\t\t\t\t\t\t * distinction this tool exists to draw — a standing shortfall against a dated\n\t\t\t\t\t\t\t * collapse — is the one the encoding could not show. It had to be asserted in\n\t\t\t\t\t\t\t * prose by a detector instead, which made that sentence the reader's only\n\t\t\t\t\t\t\t * witness to its own subject.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * As a proportion it draws itself: a standing shortfall is a flat line, a\n\t\t\t\t\t\t\t * collapse is a step with a date under it, a gradual decay is a ramp that\n\t\t\t\t\t\t\t * plainly is not an event, and a partial recovery is a line that visibly did\n\t\t\t\t\t\t\t * not return. The reader can check the sentence rather than trust it.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t...((data.crossSource ?? []).some((d) => d.vercelPageviews !== null && d.ga4Pageviews !== null)\n\t\t\t\t\t\t\t\t? [{\n\t\t\t\t\t\t\t\t\tkey: 'coverage',\n\t\t\t\t\t\t\t\t\tlabel: 'Share GA4 saw',\n\t\t\t\t\t\t\t\t\tsource: 'GA4' as const,\n\t\t\t\t\t\t\t\t\tcomplete: true,\n\t\t\t\t\t\t\t\t\tunit: 'percent' as const,\n\t\t\t\t\t\t\t\t\t// Fixed at the bottom, open at the top.\n\t\t\t\t\t\t\t\t\t//\n\t\t\t\t\t\t\t\t\t// The lower bound is fixed so a site at a flat 20% cannot scale itself\n\t\t\t\t\t\t\t\t\t// to full height and read as healthy. The UPPER bound is not clamped,\n\t\t\t\t\t\t\t\t\t// because over-counting is a real fault with its own interpretation\n\t\t\t\t\t\t\t\t\t// branch — and the card further down this very file refuses the same\n\t\t\t\t\t\t\t\t\t// clamp in prose, saying `Math.min(1, …)` rendered a tag reporting\n\t\t\t\t\t\t\t\t\t// 250% of reality as a flat 100% under the heading \"How much GA4 is\n\t\t\t\t\t\t\t\t\t// seeing\", i.e. perfect. One quantity, two opposite policies, one\n\t\t\t\t\t\t\t\t\t// file: the row drew a double-firing tag as a healthy flat line while\n\t\t\t\t\t\t\t\t\t// the sentence beside it called the over-count out.\n\t\t\t\t\t\t\t\t\tdomain: [0, Math.max(1, ...(data.crossSource ?? [])\n\t\t\t\t\t\t\t\t\t\t.map((d) => (d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0\n\t\t\t\t\t\t\t\t\t\t\t? d.ga4Pageviews / d.vercelPageviews\n\t\t\t\t\t\t\t\t\t\t\t: 0)))] as [number, number],\n\t\t\t\t\t\t\t\t\tpoints: (data.crossSource ?? []).map((d) => ({\n\t\t\t\t\t\t\t\t\t\tdate: d.date,\n\t\t\t\t\t\t\t\t\t\tvalue: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0\n\t\t\t\t\t\t\t\t\t\t\t? d.ga4Pageviews / d.vercelPageviews\n\t\t\t\t\t\t\t\t\t\t\t: null,\n\t\t\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t\t\t\t// The most valuable ghost in the chart: a collapse reads as a step away\n\t\t\t\t\t\t\t\t\t// from last period's flat line, whether or not the detector fired.\n\t\t\t\t\t\t\t\t\tcomparison: previous?.crossSource?.length\n\t\t\t\t\t\t\t\t\t\t? previous.crossSource.map((d) => ({\n\t\t\t\t\t\t\t\t\t\t\tdate: d.date,\n\t\t\t\t\t\t\t\t\t\t\tvalue: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0\n\t\t\t\t\t\t\t\t\t\t\t\t? d.ga4Pageviews / d.vercelPageviews\n\t\t\t\t\t\t\t\t\t\t\t\t: null,\n\t\t\t\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\t\t\t\t: undefined,\n\t\t\t\t\t\t\t\t}]\n\t\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t\t// ORDERS ALWAYS, revenue only when it covers every order.\n\t\t\t\t\t\t\t//\n\t\t\t\t\t\t\t// This used to draw revenue whenever ANY day had a revenue figure, and\n\t\t\t\t\t\t\t// otherwise orders — never both. Combined with the server filling a day's\n\t\t\t\t\t\t\t// revenue as `revenueByDate[date] ?? 0`, that meant a day carrying a real\n\t\t\t\t\t\t\t// order with no recorded amount arrived as a measured ZERO, and the row drew\n\t\t\t\t\t\t\t// a zero-tick on it: the mark whose own comment says \"measured, and it was\n\t\t\t\t\t\t\t// nothing\".\n\t\t\t\t\t\t\t//\n\t\t\t\t\t\t\t// At Darden 11 of 69 orders carry an amount, so the flagship chart was\n\t\t\t\t\t\t\t// asserting that no sale happened on 58 days that had sales — the package's\n\t\t\t\t\t\t\t// founding distinction inverted inside its most prominent encoding. Orders\n\t\t\t\t\t\t\t// are exact and complete whatever the amounts say, so they are the row that\n\t\t\t\t\t\t\t// is always honest.\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'orders',\n\t\t\t\t\t\t\t\tlabel: 'Orders',\n\t\t\t\t\t\t\t\tsource: 'Sanity' as const,\n\t\t\t\t\t\t\t\tcomplete: true,\n\t\t\t\t\t\t\t\tunit: 'count' as const,\n\t\t\t\t\t\t\t\t// A sale is an event on a date, not a level that varies day to day.\n\t\t\t\t\t\t\t\tmark: 'events' as const,\n\t\t\t\t\t\t\t\tpoints: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.orders })),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t...(revenueCoversEveryOrder(data)\n\t\t\t\t\t\t\t\t? [{\n\t\t\t\t\t\t\t\t\tkey: 'revenue',\n\t\t\t\t\t\t\t\t\tlabel: 'Revenue',\n\t\t\t\t\t\t\t\t\tsource: 'Sanity' as const,\n\t\t\t\t\t\t\t\t\tcomplete: true,\n\t\t\t\t\t\t\t\t\tunit: 'money' as const,\n\t\t\t\t\t\t\t\t\tmark: 'events' as const,\n\t\t\t\t\t\t\t\t\tpoints: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.revenue })),\n\t\t\t\t\t\t\t\t}]\n\t\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{data.vercelDailyUnavailable && (\n\t\t\t\t\t\t// Restored. Vercel's aggregate endpoint caps at 62 day-buckets, so on Quarter and\n\t\t\t\t\t\t// Year the pageview row has no data at all — and the sentence explaining that was\n\t\t\t\t\t\t// deleted along with the \"Day by day\" section when this panel was split, leaving\n\t\t\t\t\t\t// two of the four ranges drawing an empty band under \"Everything, on one time\n\t\t\t\t\t\t// axis\" on the default tab with nothing to say why.\n\t\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\t\tVercel reports a range this long in weekly buckets, so the pageview row is empty\n\t\t\t\t\t\t\there. Choose Week or Month to see it.\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<ChartData<CrossSourceDay>\n\t\t\t\t\t\t// No note. \"A negative miss means GA4 counted more than Vercel that day —\n\t\t\t\t\t\t// usually a tag firing twice\" was the THIRD copy of that sentence on one\n\t\t\t\t\t\t// screen: the timeline's caution card says it against a live finding, and the\n\t\t\t\t\t\t// over-count notice says it again beside the capture estimates. A reader who\n\t\t\t\t\t\t// meets a claim three times stops reading the register it arrives in. It\n\t\t\t\t\t\t// stays where it is attached to something that actually happened.\n\t\t\t\t\t\tlabel=\"Show these figures as a table\"\n\t\t\t\t\t\trows={data.crossSource ?? []}\n\t\t\t\t\t\trowKey={(d) => d.date}\n\t\t\t\t\t\texportName=\"timeline\"\n\t\t\t\t\t\t// Filterable, like every other table in the tool. This one carries the columns\n\t\t\t\t\t\t// the whole package exists for and was the only table with no filter box.\n\t\t\t\t\t\tfilterOn={(d) => d.date}\n\t\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t\t{ key: 'date', label: 'Date', sortValue: (d) => d.date, render: (d) => <Text size={1}>{d.date}</Text> },\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'vercel', label: 'Pageviews', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => d.vercelPageviews,\n\t\t\t\t\t\t\t\trender: (d) => <Text size={1}>{d.vercelPageviews === null ? '—' : formatCount(d.vercelPageviews)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'ga4', label: 'Seen by GA4', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => d.ga4Pageviews,\n\t\t\t\t\t\t\t\trender: (d) => <Text size={1}>{d.ga4Pageviews === null ? '—' : formatCount(d.ga4Pageviews)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t\t * The disagreement, as a number you can sort on.\n\t\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t\t * This table held `Pageviews` and `Seen by GA4` as separate columns and\n\t\t\t\t\t\t\t\t * nothing joining them, so the one quantity this whole tool exists to\n\t\t\t\t\t\t\t\t * surface was the one column missing from the only table that could hold\n\t\t\t\t\t\t\t\t * it. \"Which day did GA4 lose most\" was answerable by eye, or by the\n\t\t\t\t\t\t\t\t * single worst-day sentence under the chart, and by nothing else — not\n\t\t\t\t\t\t\t\t * sortable, not filterable, not in the CSV.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tkey: 'missed', label: 'Missed by GA4', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => gapOf(d),\n\t\t\t\t\t\t\t\trender: (d) => {\n\t\t\t\t\t\t\t\t\tconst gap = gapOf(d)\n\t\t\t\t\t\t\t\t\treturn <Text size={1}>{gap === null ? '—' : formatCount(gap)}</Text>\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t\t * The same disagreement as a proportion, because the two rank days\n\t\t\t\t\t\t\t\t * differently and both questions are real: the absolute column finds the\n\t\t\t\t\t\t\t\t * day that cost the most, this one finds the day the instrument worked\n\t\t\t\t\t\t\t\t * worst. Sorting on it is how a reader dates a collapse.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tkey: 'coverage', label: 'Share GA4 saw', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => coverageOf(d),\n\t\t\t\t\t\t\t\t// The FORMATTED value, matching the cell. Passing the raw ratio through\n\t\t\t\t\t\t\t\t// shipped 0.06666666666666667 into a CSV column whose cell reads \"7%\" —\n\t\t\t\t\t\t\t\t// verbatim the case exportValue exists to prevent.\n\t\t\t\t\t\t\t\texportValue: (d) => {\n\t\t\t\t\t\t\t\t\tconst coverage = coverageOf(d)\n\t\t\t\t\t\t\t\t\treturn coverage === null ? null : formatPercent(coverage, 0)\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\trender: (d) => {\n\t\t\t\t\t\t\t\t\tconst coverage = coverageOf(d)\n\t\t\t\t\t\t\t\t\treturn <Text size={1}>{coverage === null ? '—' : formatPercent(coverage, 0)}</Text>\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'orders', label: 'Orders', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => d.orders,\n\t\t\t\t\t\t\t\trender: (d) => <Text size={1}>{d.orders === null ? '—' : formatCount(d.orders)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'revenue', label: 'Revenue', numeric: true,\n\t\t\t\t\t\t\t\tsortValue: (d) => d.revenue,\n\t\t\t\t\t\t\t\texportValue: (d) => (d.revenue === null ? null : formatMoney(d.revenue, data.currency ?? null)),\n\t\t\t\t\t\t\t\trender: (d) => <Text size={1}>{d.revenue === null ? '—' : formatMoney(d.revenue, data.currency ?? null)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t)}\n\n\n\t\t\t{/* STAYS HERE, open, and a real Section.\n\t\t\t\n\t\t\t A review argued this belongs on Acquisition — an email send is a channel with a date\n\t\t\t and a click count, sitting on the default tab only because Mailchimp arrives in this\n\t\t\t report's envelope. Two things say otherwise.\n\t\t\t\n\t\t\t The valuable half cannot travel. `attributeToSends` computes \"Orders after\" and\n\t\t\t \"Per 1,000 sent\" from `crossSource`, the daily order series assembled on THIS report,\n\t\t\t and `timelineEvents` derives its markers from these same campaigns — so moving the\n\t\t\t table would make Acquisition fetch Mailchimp and the order series independently while\n\t\t\t this report kept fetching Mailchimp anyway. Two duplicated upstream calls to relocate\n\t\t\t a table.\n\t\t\t\n\t\t\t And it must not fold. \"Did that send sell anything\" is asked within hours of every\n\t\t\t send, two to four times a month; this is the one email question Google Analytics\n\t\t\t cannot answer, because both the send times and the order times here are exact. The\n\t\t\t band below folds material that is CONSULTED. This is read.\n\t\t\t\n\t\t\t What it gains is the subtitle, which carries the one claim these columns do not\n\t\t\t make — the half a reader who takes \"after\" for \"because of\" will otherwise miss. */}\n\t\t\t{(data.campaigns?.length ?? 0) > 0 && (\n\t\t\t\t<Section\n\t\t\t\t\ttitle=\"Email campaigns\"\n\t\t\t\t\tsubtitle=\"What your order book recorded after each send — what happened next, not what the send caused.\"\n\t\t\t\t>\n\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\tClicks are distinct subscribers. Opens are not shown: Apple Mail fetches images on\n\t\t\t\t\t\tthe recipient’s behalf, so an open is often the mail client rather than a person.\n\t\t\t\t\t</Text>\n\t\t\t\t\t{/* Said once, above the table, rather than in a tooltip on every cell. The claim these\n\t\t\t\t\t columns do NOT make is the important half, and a reader who takes \"after\" for\n\t\t\t\t\t \"because of\" will over-credit the newsletter. */}\n\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\tThe last two columns count what your order book records in the {SEND_WINDOW_DAYS} days\n\t\t\t\t\t\tafter each send, ending early if another send lands first. They are what happened\n\t\t\t\t\t\tnext, not what the send caused — but unlike anything GA4 can tell you about\n\t\t\t\t\t\temail, both the send times and the order times are exact.\n\t\t\t\t\t</Text>\n\t\t\t\t\t<SortableTable<EmailCampaign>\n\t\t\t\t\t\tcaption=\"Email campaigns by clicks\"\n\t\t\t\t\t\tinitialSort=\"clicks\"\n\t\t\t\t\t\trows={data.campaigns ?? []}\n\t\t\t\t\t\trowKey={(c) => `${c.sentAt}-${c.title}`}\n\t\t\t\t\t\tfilterOn={(c) => `${c.title} ${c.subject}`}\n\t\t\t\t\t\tfilterPlaceholder=\"Filter campaigns\"\n\t\t\t\t\t\texportName=\"email-campaigns\"\n\t\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'title',\n\t\t\t\t\t\t\t\tlabel: 'Campaign',\n\t\t\t\t\t\t\t\tsortValue: (c) => c.title,\n\t\t\t\t\t\t\t\trender: (c) => (\n\t\t\t\t\t\t\t\t\t<Stack space={1}>\n\t\t\t\t\t\t\t\t\t\t<Text size={1}>{c.title}</Text>\n\t\t\t\t\t\t\t\t\t\t<Text size={0} muted>{c.sentAt.slice(0, 10)}</Text>\n\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{ key: 'sent', label: 'Sent', numeric: true, sortValue: (c) => c.sent, render: (c) => <Text size={1}>{formatCount(c.sent)}</Text> },\n\t\t\t\t\t\t\t// No Opens column. The caption above the table told the reader, in the tool's\n\t\t\t\t\t\t\t// own words, to \"sort on clicks, not opens\" because Apple Mail Privacy\n\t\t\t\t\t\t\t// Protection fetches images on the recipient's behalf. A column whose own\n\t\t\t\t\t\t\t// caption instructs you not to read it should not be a column — and the\n\t\t\t\t\t\t\t// warning gets shorter as a result.\n\t\t\t\t\t\t\t{ key: 'clicks', label: 'Clicks', numeric: true, sortValue: (c) => c.clicks, render: (c) => <Text size={1}>{formatCount(c.clicks)}</Text> },\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'unsub',\n\t\t\t\t\t\t\t\tlabel: 'Unsubscribed',\n\t\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t\tsortValue: (c) => c.unsubscribed,\n\t\t\t\t\t\t\t\trender: (c) => <Text size={1} muted>{formatCount(c.unsubscribed)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'ordersAfter',\n\t\t\t\t\t\t\t\tlabel: 'Orders after',\n\t\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t\tsortValue: (c) => c.ordersAfter ?? null,\n\t\t\t\t\t\t\t\texportValue: (c) => c.ordersAfter ?? null,\n\t\t\t\t\t\t\t\trender: (c) => (\n\t\t\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\t\t\t{c.ordersAfter === null || c.ordersAfter === undefined ? '—' : formatCount(c.ordersAfter)}\n\t\t\t\t\t\t\t\t\t\t{/* A send on the last day of the range has not had its days yet. Without\n\t\t\t\t\t\t\t\t\t\t this it reads as a campaign that sold nothing, which is a conclusion\n\t\t\t\t\t\t\t\t\t\t about the campaign drawn from the shape of the window. */}\n\t\t\t\t\t\t\t\t\t\t{c.windowComplete === false && c.ordersAfter !== null && c.ordersAfter !== undefined && (\n\t\t\t\t\t\t\t\t\t\t\t<Text as=\"span\" size={0} muted> so far</Text>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'revenuePerThousand',\n\t\t\t\t\t\t\t\tlabel: 'Per 1,000 sent',\n\t\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t\t// Per thousand, not per send: a foundry's sends run to thousands of addresses\n\t\t\t\t\t\t\t\t// against a handful of orders, so per-send lands at fractions of a cent and\n\t\t\t\t\t\t\t\t// every campaign renders as the same rounded zero.\n\t\t\t\t\t\t\t\tsortValue: (c) => revenuePerThousandSent(c),\n\t\t\t\t\t\t\t\texportValue: (c) => revenuePerThousandSent(c),\n\t\t\t\t\t\t\t\trender: (c) => {\n\t\t\t\t\t\t\t\t\tconst value = revenuePerThousandSent(c)\n\t\t\t\t\t\t\t\t\tif (value === null) return <Text size={1}>—</Text>\n\t\t\t\t\t\t\t\t\t// The rate AND its numerator, because the denominator is already a column.\n\t\t\t\t\t\t\t\t\t//\n\t\t\t\t\t\t\t\t\t// A card gets a toggle to turn a ratio over and show what it is made of;\n\t\t\t\t\t\t\t\t\t// a table cannot, without twenty buttons. But a table does not need one:\n\t\t\t\t\t\t\t\t\t// `Sent` is a column already, so printing the money underneath completes\n\t\t\t\t\t\t\t\t\t// the arithmetic in place. The reader can see that US$350 per thousand is\n\t\t\t\t\t\t\t\t\t// US$700 over 2,000 addresses, and judge it.\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<Stack space={1}>\n\t\t\t\t\t\t\t\t\t\t\t<Text size={1}>{formatMoney(value, data.currency ?? null)}</Text>\n\t\t\t\t\t\t\t\t\t\t\t{c.revenueAfter !== null && c.revenueAfter !== undefined && (\n\t\t\t\t\t\t\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\t\t\t\t\t\t\tfrom {formatMoney(c.revenueAfter, data.currency ?? null)}\n\t\t\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\t\t\t\t</Section>\n\t\t\t)}\n\n\t\t</div>\n\t)\n}\n\n/**\n * One line saying whether this week was normal.\n *\n * Every input was already computed and the reader was left to derive the conclusion themselves,\n * from levels, across four tabs, by comparing arrows. For someone with five minutes on a Monday\n * that derivation IS the work, and the tool was making them do all of it.\n */\nfunction Verdict({ data, previous }: { data: MeasurementHealthData; previous?: MeasurementHealthData }): React.ReactElement | null {\n\tconst parts: string[] = []\n\n\tconst say = (label: string, now: MetricValue | undefined, before: MetricValue | undefined, unit: 'money' | 'count') => {\n\t\tconst a = metricSortValue(now)\n\t\tconst b = metricSortValue(before)\n\t\tif (a === null) return\n\t\tif (b === null || b === 0) {\n\t\t\tparts.push(`${label} ${unit === 'money' ? formatMoney(a, data.currency ?? null) : formatCount(a)}`)\n\t\t\treturn\n\t\t}\n\t\tconst change = (a - b) / Math.abs(b)\n\t\t/*\n\t\t * \"Flat\" carries its number when there is one.\n\t\t *\n\t\t * Two definitions of flat sat ten pixels apart on the same panel: this line called a move\n\t\t * under 5% flat, while the card beneath it drew an arrow above 0.5% — so the headline could\n\t\t * read \"Traffic flat\" in the largest type directly above a Traffic card reading \"↑ +4% from\n\t\t * 2,266\". The suppression is still right at these volumes; contradicting the card was not.\n\t\t * Saying \"little changed (+4%)\" keeps the judgement and agrees with what is on screen.\n\t\t */\n\t\tif (Math.abs(change) < 0.05) {\n\t\t\tparts.push(Math.abs(change) < 0.005\n\t\t\t\t? `${label} flat`\n\t\t\t\t: `${label} little changed (${change > 0 ? '+' : '−'}${formatPercent(Math.abs(change), 0)})`)\n\t\t}\n\t\t/*\n\t\t * A money move carries its amounts, and a percentage alone is suppressed on thin volumes.\n\t\t *\n\t\t * \"Revenue down 62%\" was printed in the largest type on the page. At two orders a month that\n\t\t * is one order not landing, and the card beneath — which does print its baseline — is smaller\n\t\t * and read second. Every neighbouring component already knows this: Delta's own comment says\n\t\t * an 18% move might be one order, and the typeface blurb says a family with one or two sales\n\t\t * swings a long way. The headline was the one place that skipped it.\n\t\t */\n\t\telse if (unit === 'money') {\n\t\t\tparts.push(`${label} ${change > 0 ? 'up' : 'down'} to ${formatMoney(a, data.currency ?? null)} from ${formatMoney(b, data.currency ?? null)}`)\n\t\t}\n\t\t// Counts below the floor state themselves rather than a ratio, exactly as Delta now does on\n\t\t// the card beside this sentence. Otherwise the verdict read \"Orders up 75%\" in the largest\n\t\t// type on the tab while the Orders card directly beneath it said \"↑ from 4\" with no\n\t\t// percentage at all — the headline and its own evidence disagreeing on one screen, which is\n\t\t// the specific failure this whole review kept finding.\n\t\telse if (Math.abs(b) < MIN_DELTA_BASE) {\n\t\t\tparts.push(`${label} ${formatCount(a)}, was ${formatCount(b)}`)\n\t\t}\n\t\telse parts.push(`${label} ${change > 0 ? 'up' : 'down'} ${formatPercent(Math.abs(change), 0)}`)\n\t}\n\n\tsay('Revenue', data.revenue, previous?.revenue, 'money')\n\t// Orders, between the two. At single-digit volumes this is the least noisy business signal the\n\t// tool has, and the verdict omitted it while finding room for a clause about GA4's health.\n\tsay('Orders', data.orders, previous?.orders, 'count')\n\tsay('Traffic', data.vercelPageviews, previous?.vercelPageviews, 'count')\n\n\t// Not a verdict clause. That you sent an email is not a finding — you sent it — and it was\n\t// occupying a slot in the one line the reader is meant to act on. The campaigns table below\n\t// says how many, and the timeline marks when.\n\n\t// What \"broken\" means, in priority order.\n\t//\n\t// This used to consult `capture.discrepancy` alone — which is null whenever fewer than two\n\t// capture estimates exist, and at seven orders a quarter the orders and email estimates are\n\t// almost always below their minimum denominator on a Week range. So the only surviving estimate\n\t// was pageviews, the discrepancy was null, and the line appended \"nothing broken\".\n\t//\n\t// The 24 August collapse — an 86% shortfall running ten days, the founding failure this whole\n\t// package cites — would therefore have read, on a Monday: \"Revenue flat · traffic flat ·\n\t// nothing broken\". The figure that names it was computed and rendered two tabs away.\n\t// Graded, not a single cliff at 0.6. The threshold was one hard step, so a site where GA4 saw\n\t// 45% of its traffic — less than half — got a clean bill of health in the largest type on the\n\t// default tab, and a twelve-day collapse inside a ninety-day quarter averaged down below the\n\t// line and vanished entirely.\n\tconst shortfall = data.shortfallRatio\n\tconst seeing = shortfall !== null && shortfall !== undefined\n\t\t? `GA4 is seeing ${formatPercent(1 - shortfall, 0)} of your traffic`\n\t\t: null\n\tconst broken = shortfall === null || shortfall === undefined\n\t\t? (data.capture?.discrepancy ? 'measurement disagrees between sources' : null)\n\t\t: shortfall > 0.6\n\t\t\t? `${seeing} — treat its figures as broken`\n\t\t\t: shortfall > 0.35\n\t\t\t\t? `${seeing} — its figures are undercounts, not what happened`\n\t\t\t\t: data.capture?.discrepancy\n\t\t\t\t\t? 'measurement disagrees between sources'\n\t\t\t\t\t: null\n\t// NOT pushed into `parts`. It was joined to the business facts with a middot and set in the same\n\t// size, so three facts about the foundry and one about Google Analytics were grammatical peers —\n\t// and on a site where GA4 sees a fifth of its traffic, the amber alarm on the default tab led\n\t// with the plumbing while the revenue sat inside it as a clause. Every word is kept; only the\n\t// rank changes.\n\n\t// Not null.\n\t//\n\t// `parts` fills only from figures that arrived, so this branch is reached exactly where the\n\t// reader most needs a sentence: a new site, a window with no orders, or a route older than the\n\t// fields the verdict reads. The tab then rendered six em-dashes and a footer, with nothing\n\t// anywhere saying there was nothing to report — which reads as the tool being broken rather\n\t// than the window being empty.\n\t// `broken` no longer fills `parts`, so this guard has to count it too — otherwise a window with a\n\t// coverage reading and no business figures, which is exactly the state where that reading is the\n\t// only thing worth saying, would render \"No figures arrived\".\n\tif (parts.length === 0 && !broken) {\n\t\treturn (\n\t\t\t<Card padding={3} radius={2} tone=\"transparent\" style={{ borderLeftWidth: 3, borderLeftStyle: 'solid' }}>\n\t\t\t\t<Text size={4}>No figures arrived for this window.</Text>\n\t\t\t</Card>\n\t\t)\n\t}\n\n\t// \"Nothing broken\" needs a positive test, not the absence of a warning. It used to be appended\n\t// whenever `capture.discrepancy` was falsy — which includes capture being absent entirely, so an\n\t// older route or an unconfigured site printed a confident all-clear on no evidence at all.\n\t// No closing claim at all.\n\t//\n\t// This used to append \"· nothing broken\" — a statement about the site, the checkout, the orders\n\t// and four other tabs, resting on a single ratio about Google Analytics. It read as \"the\n\t// business is fine\". Between a fifth and a third of loss the coverage is worth STATING, because\n\t// it is a real qualifier on the traffic figure beside it, but it is not a verdict; below that,\n\t// silence. Silence here means nothing was flagged, which is all this line ever knew.\n\tconst noticeable = shortfall !== null && shortfall !== undefined && shortfall > 0.2\n\tconst closing = broken || !noticeable ? '' : ` · ${seeing}`\n\n\treturn (\n\t\t<Card\n\t\t\tpadding={3}\n\t\t\tradius={2}\n\t\t\ttone={broken ? 'caution' : 'transparent'}\n\t\t\tborder\n\t\t\t// An explicit border alongside the tone: the compat shim's fallback drops `tone`, and\n\t\t\t// this card is the tool's only alarm. Losing its colour must not lose the signal.\n\t\t\tstyle={broken ? { borderLeftWidth: 3, borderLeftStyle: 'solid' } : undefined}\n\t\t>\n\t\t\t<Stack space={2}>\n\t\t\t\t{/* size={4} — 21px, the same as the figures beneath it.\n\t\t\t\t\n\t\t\t\t An earlier pass moved this 15 -> 18px with a comment claiming it had been fixed,\n\t\t\t\t but `MetricFigure` defaults to size={4} = 21px, so the panel's one sentence was\n\t\t\t\t still smaller than the cards it summarises and still read as their caption. Parity\n\t\t\t\t is the floor here: this is the only line the tool exists to say. */}\n\t\t\t\t{parts.length > 0 && (\n\t\t\t\t\t<Text size={4} weight={broken ? 'semibold' : 'medium'}>{parts.join(' \\u00b7 ')}{closing}</Text>\n\t\t\t\t)}\n\t\t\t\t{/* The instrument, beneath and quieter. It is a real qualifier on the traffic figure\n\t\t\t\t and it stays on the alarm card — it is simply no longer the first thing read. */}\n\t\t\t\t{broken && <Text size={1} muted>{broken}</Text>}\n\t\t\t</Stack>\n\t\t</Card>\n\t)\n}\n\n/**\n * Data health — the instrument, deliberately last.\n *\n * Everything here answers one question: can I trust the numbers on the other tabs. It was two\n * separate tabs answering that question, which is one more than it deserves out of five.\n */\nexport function DataHealthPanel({ data, diagnostics }: { data: MeasurementHealthData; diagnostics?: DiagnosticReport }): React.ReactElement {\n\t// Whether a part-inside-whole bar is honest here — see the comment at the render site.\n\tconst contained = isContainment(data.vercelPageviews, data.ga4Pageviews)\n\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t{/* The reading is always shown. It used to be nested inside the shortfall block, so a\n\t\t\t range where only one source answered rendered bare numbers and no explanation of why\n\t\t\t there was nothing to compare — the state where the explanation matters most. */}\n\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t<Stack space={2}>\n\t\t\t\t\t{data.shortfallRatio !== null && (\n\t\t\t\t\t\t<Text size={1} weight=\"semibold\">\n\t\t\t\t\t\t\t{/* Named by direction rather than always as a GA4 shortfall. The ratio goes\n\t\t\t\t\t\t\t negative whenever GA4 sees more than Vercel — routine where Vercel's\n\t\t\t\t\t\t\t collection started later than the range, as on MCKL — and the label used\n\t\t\t\t\t\t\t to read \"GA4 shortfall\" over an absolute value, stating the opposite of\n\t\t\t\t\t\t\t the truth while the sentence below it said \"more\". */}\n\t\t\t\t\t\t\t{data.shortfallRatio >= 0\n\t\t\t\t\t\t\t\t? `GA4 saw ${formatPercent(data.shortfallRatio, 1)} fewer pageviews than Vercel`\n\t\t\t\t\t\t\t\t: `GA4 saw ${formatPercent(-data.shortfallRatio, 1)} more pageviews than Vercel`}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)}\n\t\t\t\t\t<Text size={1} muted>{data.interpretation}</Text>\n\t\t\t\t</Stack>\n\t\t\t</Card>\n\n\t\t\t{/* The evidence, AFTER the reading it supports.\n\t\t\t\n\t\t\t This tab exists to answer one question — can I trust the other tabs — and it opened\n\t\t\t with a section title, an explanatory line and a bar, then stated the answer fourth.\n\t\t\t A reader with five minutes met the working before the conclusion. */}\n\t\t\t<div style={stackBlock}>\n\t\t\t\t<SectionTitle title=\"Pageviews, source against source\" />\n\t\t\t\t<Text size={1} muted>\n\t\t\t\t\tBoth counting pageviews. Google Analytics is blockable, so seeing fewer is normal.\n\t\t\t\t</Text>\n\t\t\t\t{/* One bar, because this is a part inside a whole and not two rivals.\n\t\t\t\t\n\t\t\t\t As two peer bars scaled to the larger of the pair, Vercel's was full width on\n\t\t\t\t every site and every range — no information at all — and a caption had to say in\n\t\t\t\t words that the other was \"a subset of the bar above, not a rival measurement\".\n\t\t\t\t The encoding asserted the opposite of the truth and the prose took it back.\n\t\t\t\t\n\t\t\t\t ContainmentBar returns null where the part exceeds the whole, which is real:\n\t\t\t\t GA4 counts more than Vercel wherever Vercel's collection started after the range\n\t\t\t\t began. There the two-bar form is still the honest picture, so it is kept as the\n\t\t\t\t fallback rather than forcing a containment that does not hold. */}\n\t\t\t\t{contained ? (\n\t\t\t\t\t<ContainmentBar\n\t\t\t\t\t\twholeLabel=\"Pageviews Vercel counted\"\n\t\t\t\t\t\twhole={data.vercelPageviews}\n\t\t\t\t\t\tpartLabel=\"Seen by Google Analytics\"\n\t\t\t\t\t\tpart={data.ga4Pageviews}\n\t\t\t\t\t\tmissingLabel=\"it missed\"\n\t\t\t\t\t/>\n\t\t\t\t) : isContainment(data.ga4Pageviews, data.vercelPageviews) ? (\n\t\t\t\t\t/* Still a containment — the operands are simply the other way round.\n\t\t\t\t\t\n\t\t\t\t\t When GA4 counts MORE than Vercel, it is because Vercel started collecting after\n\t\t\t\t\t the range began, so Vercel's count is the subset. Falling back to two peer bars\n\t\t\t\t\t scaled to the larger put GA4 at full width saying nothing, which is verbatim the\n\t\t\t\t\t defect ContainmentBar was built to kill. */\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<ContainmentBar\n\t\t\t\t\t\t\twholeLabel=\"Pageviews Google Analytics counted\"\n\t\t\t\t\t\t\twhole={data.ga4Pageviews}\n\t\t\t\t\t\t\tpartLabel=\"Also counted by Vercel\"\n\t\t\t\t\t\t\tpart={data.vercelPageviews}\n\t\t\t\t\t\t\tmissingLabel=\"Vercel did not see\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\tVercel counted fewer because its collection started after this range began, not\n\t\t\t\t\t\t\tbecause the traffic was not there.\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t) : (\n\t\t\t\t\t/* Neither containment holds, because only one source answered.\n\t\t\t\t\t\n\t\t\t\t\t Deleting ComparisonBar took this case with it: with GA4 down, both containments\n\t\t\t\t\t return null and the section rendered NOTHING — not even the figure Vercel did\n\t\t\t\t\t report. That is the state where the reader most needs to see what survived, and\n\t\t\t\t\t it is the one the card below spends a comment explaining. */\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<Label size={1} muted>Pageviews Vercel counted</Label>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.vercelPageviews, OLDER_ROUTE)} label=\"Vercel pageviews\" size={3} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<Label size={1} muted>Seen by Google Analytics</Label>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.ga4Pageviews, OLDER_ROUTE)} label=\"GA4 pageviews\" size={3} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t\tOnly one source answered for this range, so there is nothing to compare.\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\n\t\t\t{/* Moved up from the bottom of the tab.\n\t\t\t\n\t\t\t Its own checks are the only thing on this tab a reader can ACT on — each carries a\n\t\t\t remedy — and it sat fifth, after two sections of measurement and two of reference.\n\t\t\t The tab asks \"can I trust this\"; the answer to \"then what do I fix\" should not be\n\t\t\t below the material it explains. */}\n\t\t\t{diagnostics && (\n\t\t\t\t/* Open, and primary. This is the longest block on the tab and the obvious candidate\n\t\t\t\t for folding away — but the tab it sits on exists to answer \"what should I fix\n\t\t\t\t before trusting any of this\", and these checks ARE that answer. Folding it would\n\t\t\t\t have left Data health opening on a summary of the problem with the solution behind\n\t\t\t\t a click. It stays collapsible so a reader who has read it can get it out of the\n\t\t\t\t way. */\n\t\t\t\t<Section\n\t\t\t\t\ttitle=\"Configuration\"\n\t\t\t\t\tsubtitle=\"What this site has wired up, and what it is missing.\"\n\t\t\t\t\tcollapsible\n\t\t\t\t>\n\t\t\t\t\t<DiagnosticsPanel data={diagnostics} />\n\t\t\t\t</Section>\n\t\t\t)}\n\n\t\t\t{((data.capture?.estimates?.length ?? 0) > 0 || data.estimatedSessions?.status === 'estimated') && (\n\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t<SectionTitle title=\"How much GA4 is seeing\" />\n\t\t\t\t\t{/* One axis, not three cards.\n\t\t\t\t\t\n\t\t\t\t\t capture.ts is explicit that the three estimates are deliberately NOT averaged,\n\t\t\t\t\t because the disagreement between them is the useful output. A card grid makes\n\t\t\t\t\t that disagreement a subtraction the reader does by eye — and auto-fit reorders\n\t\t\t\t\t the cards by pane width, so even their sequence moves. */}\n\t\t\t\t\t<EstimateDotPlot\n\t\t\t\t\t\testimates={data.capture?.estimates ?? []}\n\t\t\t\t\t\tlabelFor={(basis) => basis === 'orders' ? 'Checked against your orders'\n\t\t\t\t\t\t\t: basis === 'email' ? 'Checked against email clicks'\n\t\t\t\t\t\t\t\t: 'Checked against Vercel'}\n\t\t\t\t\t\tintervalFor={samplingInterval}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{/* The point of holding three estimates. Rendered in caution tone because a\n\t\t\t\t\t disagreement is a finding, not context. */}\n\t\t\t\t\t{data.capture?.discrepancy && (\n\t\t\t\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t\t\t<Text size={1} weight=\"medium\">The sources disagree, and that is informative</Text>\n\t\t\t\t\t\t\t\t<Text size={1}>{data.capture.discrepancy}</Text>\n\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{data.estimatedSessions && data.estimatedSessions.status === 'estimated' && (\n\t\t\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t\t<Label size={1} muted>Sessions, corrected for what GA4 misses</Label>\n\t\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.estimatedSessions, OLDER_ROUTE)} label=\"Estimated sessions\" />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\n\n\t\t\t{/* A real Section now, not a bare title — so it can carry its own tone, fold, and keep its\n\t\t\t instruction visible while folded. The instruction moved into the subtitle for exactly\n\t\t\t that reason: it is the one sentence a reader needs before they go looking, and Section\n\t\t\t renders a subtitle outside the fold.\n\t\t\t\n\t\t\t Consulted once ever, when configuring which statuses count as a sale. */}\n\t\t\t{Object.keys(data.orderStatuses ?? {}).length > 0 && (\n\t\t\t\t<Section\n\t\t\t\t\ttitle=\"Order statuses in this range\"\n\t\t\t\t\ttone=\"secondary\"\n\t\t\t\t\tsubtitle=\"What the orders actually say, before any filtering. Use these values to set which statuses count as a sale.\"\n\t\t\t\t\tcollapsible\n\t\t\t\t\tdefaultOpen={false}\n\t\t\t\t>\n\t\t\t\t\t{/* Without this nobody can configure which statuses count as a sale — and getting\n\t\t\t\t\t that wrong zeroes every order-derived figure in the tool with nothing on screen\n\t\t\t\t\t to explain it. */}\n\t\t\t\t\t{/* A proportion, not a row of peer cards.\n\t\t\t\t\t\n\t\t\t\t\t The reader's question is what SHARE of the order book is the status configured\n\t\t\t\t\t as a sale — and a card each, sorted by count, makes that a sum done by eye.\n\t\t\t\t\t ProportionChart already prints share-and-absolute against a stated total, so\n\t\t\t\t\t the configuration mistake that zeroes every order figure becomes visible\n\t\t\t\t\t rather than arithmetic. */}\n\t\t\t\t\t<ProportionChart\n\t\t\t\t\t\tbars={Object.entries(data.orderStatuses ?? {}).map(([status, count]) => ({\n\t\t\t\t\t\t\tkey: status,\n\t\t\t\t\t\t\tlabel: status,\n\t\t\t\t\t\t\tvalue: count,\n\t\t\t\t\t\t}))}\n\t\t\t\t\t\tformat={(n) => formatCount(n)}\n\t\t\t\t\t\ttotalLabel=\"Orders in this range\"\n\t\t\t\t\t/>\n\t\t\t\t</Section>\n\t\t\t)}\n\n\t\t\t<Section\n\t\t\t\ttitle=\"Context\"\n\t\t\t\ttone=\"secondary\"\n\t\t\t\tsubtitle=\"Different units to the figures above, and to each other.\"\n\t\t\t\tcollapsible\n\t\t\t\tdefaultOpen={false}\n\t\t\t>\n\t\t\t\t<div style={cardGrid}>\n\t\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t<Label size={1} muted>Vercel visitors</Label>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.vercelVisitors, OLDER_ROUTE)} label=\"Vercel visitors\" />\n\t\t\t\t\t\t\t{/* Not \"server-side\". Vercel Web Analytics is the @vercel/analytics client script\n\t\t\t\t\t\t\t on a first-party path — blocked by fewer lists than GA4, and blocked. */}\n\t\t\t\t\t\t\t<Text size={0} muted>Vercel’s own counter. Blocked far less often than Google Analytics, but not never — so read it as a floor on your real traffic.</Text>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t<Label size={1} muted>GA4 sessions</Label>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.ga4Sessions, OLDER_ROUTE)} label=\"GA4 sessions\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t<Label size={1} muted>Consent granted</Label>\n\t\t\t\t\t\t\t<MetricFigure metric={metricOr(data.consentRate, OLDER_ROUTE)} label=\"Consent granted, share of visitors GA4 saw\" unit=\"percent\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Card>\n\t\t\t\t</div>\n\t\t\t</Section>\n\n\t\t</div>\n\t)\n}\n\n/** Acquisition — where visitors came from, with design-industry referrers called out. */\nexport function AcquisitionPanel({ data, previous }: { data: AcquisitionData; previous?: AcquisitionData }): React.ReactElement {\n\tconst sessions = finiteOrNull(data.totalSessions)\n\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t<div style={cardGrid}>\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t<Label size={1} muted>Sessions</Label>\n\t\t\t\t\t\t<div style={figureRow}>\n\t\t\t\t\t\t\t<Text size={4}>{sessions === null ? '\\u2014' : formatCount(sessions)}</Text>\n\t\t\t\t\t\t\t<Delta current={sessions} previous={finiteOrNull(previous?.totalSessions)} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t</div>\n\n\t\t\t<div style={stackBlock}>\n\t\t\t\t<SectionTitle title=\"Traffic sources\" />\n\t\t\t\t<Text size={1} muted>Sort or filter to find a source. Excluding a row hides it from this table.</Text>\n\t\t\t\t<SortableTable<SourceRow>\n\t\t\t\t\tcaption=\"Traffic sources by sessions\"\n\t\t\t\t\tinitialSort=\"sessions\"\n\t\t\t\t\trows={data.rows ?? []}\n\t\t\t\t\t// Every dimension the report requests, not just two of them. With medium and\n\t\t\t\t\t// campaign added, several rows share a source and channel — which is the point\n\t\t\t\t\t// of the column — and a two-part key gave duplicate React keys and made one\n\t\t\t\t\t// exclude click remove every row that shared it.\n\t\t\t\t\trowKey={acquisitionRowKey}\n\t\t\t\t\tfilterOn={(row) => `${row.source} ${row.channel} ${row.medium ?? ''} ${row.campaign ?? ''}`}\n\t\t\t\t\tfilterPlaceholder=\"Filter sources\"\n\t\t\t\t\texportName=\"traffic-sources\"\n\t\t\t\t\ttruncatedNote={shortListNote(\n\t\t\t\t\t\tBoolean(data.rowsTruncated),\n\t\t\t\t\t\tBoolean(data.rowsWithheld),\n\t\t\t\t\t\t'For a foundry the tail of small design blogs is often the referral story.',\n\t\t\t\t\t)}\n\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'source',\n\t\t\t\t\t\t\tlabel: 'Source',\n\t\t\t\t\t\t\tsortValue: (row) => row.source,\n\t\t\t\t\t\t\trender: (row) => (\n\t\t\t\t\t\t\t\t<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>\n\t\t\t\t\t\t\t\t\t{/* A referrer host is a real destination, so it links. GA4's own\n\t\t\t\t\t\t\t\t\t buckets — (direct), (not set) — are not hosts and must not\n\t\t\t\t\t\t\t\t\t pretend to be, so only a dotted hostname gets an anchor. */}\n\t\t\t\t\t\t\t\t\t{/* Inside Text, not beside it. A bare anchor inherited the cell's default\n\t\t\t\t\t\t\t\t\t size while the plain branch was sized by Text, so whether a source\n\t\t\t\t\t\t\t\t\t linked changed how big it was — the rows read as two ranks of\n\t\t\t\t\t\t\t\t\t importance when the only difference is that one is a real host.\n\t\t\t\t\t\t\t\t\t The underline is what says it is a link. */}\n\t\t\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\t\t\t{isLinkableHost(row.source)\n\t\t\t\t\t\t\t\t\t\t\t? (\n\t\t\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\t\t\thref={`https://${row.source}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={sourceLink}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{row.source}\n\t\t\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t: row.source}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t{row.designIndustry && <Badge tone=\"primary\" fontSize={0}>Design</Badge>}\n\t\t\t\t\t\t\t\t\t{row.unattributed && <Badge tone=\"caution\" fontSize={0}>No source</Badge>}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'channel',\n\t\t\t\t\t\t\tlabel: 'Channel',\n\t\t\t\t\t\t\tsortValue: (row) => row.channel,\n\t\t\t\t\t\t\trender: (row) => <Text size={1}>{row.channel}</Text>,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'campaign',\n\t\t\t\t\t\t\tlabel: 'Campaign',\n\t\t\t\t\t\t\tsortValue: (row) => row.campaign,\n\t\t\t\t\t\t\texportValue: (row) => row.campaign ?? row.medium ?? '',\n\t\t\t\t\t\t\t// Every campaign, ad group and keyword used to collapse into one row, so\n\t\t\t\t\t\t\t// there was no unit of spend here that a buyer could pause.\n\t\t\t\t\t\t\trender: (row) => row.campaign\n\t\t\t\t\t\t\t\t? <Text size={1}>{row.campaign}</Text>\n\t\t\t\t\t\t\t\t: <Text size={1} muted>{row.medium ?? '—'}</Text>,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'sessions',\n\t\t\t\t\t\t\tlabel: 'Sessions',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => row.sessions,\n\t\t\t\t\t\t\trender: (row) => <Text size={1}>{formatCount(row.sessions)}</Text>,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'engagement',\n\t\t\t\t\t\t\tlabel: 'Engaged',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => finiteOrNull(row.engagementRate),\n\t\t\t\t\t\t\texportValue: (row) => { const r = finiteOrNull(row.engagementRate); return r === null ? null : formatPercent(r, 0) },\n\t\t\t\t\t\t\t// The quality signal. Ranked by volume alone, 27 Display sessions looked\n\t\t\t\t\t\t\t// equal to 27 from Search, which flatters the worst line of spend.\n\t\t\t\t\t\t\trender: (row) => finiteOrNull(row.engagementRate) === null\n\t\t\t\t\t\t\t\t? <Text size={1} muted>—</Text>\n\t\t\t\t\t\t\t\t: <Text size={1}>{formatPercent(row.engagementRate as number, 0)}</Text>,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t * What the channel BROUGHT, which is the question a foundry opens this tool\n\t\t\t\t\t\t\t * with and the one it could not answer. Sessions and engagement rank\n\t\t\t\t\t\t\t * channels by attention; nothing ranked them by money, so \"is the design\n\t\t\t\t\t\t\t * press worth chasing\" and \"did the newsletter sell anything\" were\n\t\t\t\t\t\t\t * unaskable while revenue sat one tab away, split by typeface and by\n\t\t\t\t\t\t\t * licence — the two cuts already available from the order list.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * Sorted on the SHARE, so the ordering holds even when Sanity supplies no\n\t\t\t\t\t\t\t * total and the money column is empty.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tkey: 'brought',\n\t\t\t\t\t\t\t// The hedge belongs on the SPLIT, not on the money: the total is exact and the\n\t\t\t\t\t\t\t// division of it is the estimate. \"(est.)\" on a revenue header implied the reverse.\n\t\t\t\t\t\t\tlabel: 'Revenue, split',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => finiteOrNull(row.revenueShare),\n\t\t\t\t\t\t\texportValue: (row) => {\n\t\t\t\t\t\t\t\tconst money = finiteOrNull(row.apportionedRevenue)\n\t\t\t\t\t\t\t\treturn money === null ? null : formatMoney(money, data.currency ?? null)\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\trender: (row) => {\n\t\t\t\t\t\t\t\t// finiteOrNull, not `=== null`. An older API route omits these fields\n\t\t\t\t\t\t\t\t// entirely, so they arrive as UNDEFINED — which slipped past a null check\n\t\t\t\t\t\t\t\t// and rendered \"NaN% of tracked sales\" in every Studio between publishing\n\t\t\t\t\t\t\t\t// this package and deploying the sites. That window is the normal state\n\t\t\t\t\t\t\t\t// of this repo, not an edge case.\n\t\t\t\t\t\t\t\tconst share = finiteOrNull(row.revenueShare)\n\t\t\t\t\t\t\t\t// Zero is not a measurement here. At a fifth of traffic captured, a channel\n\t\t\t\t\t\t\t\t// with four real sales and no tracked one is the ordinary case, and it was\n\t\t\t\t\t\t\t\t// rendering a currency-formatted \"$0.00 · 0% of tracked sales\" — a measured\n\t\t\t\t\t\t\t\t// zero manufactured from lossy data, which is the one thing the metric types\n\t\t\t\t\t\t\t\t// in this package exist to forbid.\n\t\t\t\t\t\t\t\tif (share === null || share === 0) return <Text size={1} muted>—</Text>\n\t\t\t\t\t\t\t\tconst money = finiteOrNull(row.apportionedRevenue)\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<Stack space={1}>\n\t\t\t\t\t\t\t\t\t\t{money !== null && <Text size={1}>{formatMoney(money, data.currency ?? null)}</Text>}\n\t\t\t\t\t\t\t\t\t\t<Text size={0} muted>{formatPercent(share, 0)} of tracked sales</Text>\n\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t]}\n\t\t\t\t/>\n\n\t\t\t\t{/* The column is deliberately allowed to be a column of dashes — a null is not a zero,\n\t\t\t\t and this package would rather look uncomfortable than round an absence down. But\n\t\t\t\t the paragraph that explains the column was gated on the SUCCESS case, so in exactly\n\t\t\t\t the state where every cell is a dash there was no explanation anywhere on screen:\n\t\t\t\t the reason went to `notices` at the foot of the panel, where it can also be folded\n\t\t\t\t behind \"N more caveats\". A column that is meant to be uncomfortable is only honest\n\t\t\t\t if it says why where it sits. */}\n\t\t\t\t{!(data.splitIsSound === true && finiteOrNull(data.actualRevenue) !== null) && (\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\tRevenue is not split across sources here.{' '}\n\t\t\t\t\t\t{finiteOrNull(data.actualRevenue) === null\n\t\t\t\t\t\t\t? 'Your orders carry no amount for this period, so there is no total to divide.'\n\t\t\t\t\t\t\t: 'Google Analytics attributed too few of these sales to a source for the division to mean anything.'}\n\t\t\t\t\t\t{' '}The sessions and engagement columns are unaffected.\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\n\t\t\t\t{data.splitIsSound === true && finiteOrNull(data.actualRevenue) !== null && (\n\t\t\t\t\t// Says exactly what was combined and what was estimated. The money in that column\n\t\t\t\t\t// is Sanity's real total spread across GA4's split — right in scale, and derived,\n\t\t\t\t\t// which is a different claim from measured. Saying so here is what makes the\n\t\t\t\t\t// column usable rather than another figure to distrust.\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t{/* The denominator the money actually covers. `actualRevenue` sums only orders\n\t\t\t\t\t\t carrying an amount and `actualOrders` counts all of them, so this read\n\t\t\t\t\t\t \"Revenue is US$3,150 from 69 orders\" where the money covered eleven — two\n\t\t\t\t\t\t different sets of orders joined by the word \"from\". */}\n\t\t\t\t\t\tRevenue is {formatMoney(finiteOrNull(data.actualRevenue) ?? 0, data.currency ?? null)} from{' '}\n\t\t\t\t\t\t{finiteOrNull(data.actualOrders) === null\n\t\t\t\t\t\t\t? 'your orders'\n\t\t\t\t\t\t\t: (data.ordersMissingTotal ?? 0) > 0\n\t\t\t\t\t\t\t\t? `the ${formatCount((data.actualOrders as number) - (data.ordersMissingTotal as number))} of ${formatCount(data.actualOrders as number)} orders that carry an amount`\n\t\t\t\t\t\t\t\t: `${formatCount(data.actualOrders as number)} orders`}{' '}in Sanity,\n\t\t\t\t\t\tsplit across the {formatCount(data.trackedPurchases ?? 0)} purchase\n\t\t\t\t\t\t{data.trackedPurchases === 1 ? '' : 's'} Google Analytics attributed to a source. Google\n\t\t\t\t\t\tAnalytics sees a fraction of your traffic, so treat this as a proportion with a real total\n\t\t\t\t\t\tbehind it rather than measured takings per channel. Two things to hold in mind: it credits\n\t\t\t\t\t\twhichever source a buyer arrived from on the visit they bought, which for a considered\n\t\t\t\t\t\tpurchase is often a bookmark rather than the blog that first sent them; and its loss is not\n\t\t\t\t\t\tperfectly even across channels — ad-blocking runs high among designers, so earned referrals\n\t\t\t\t\t\tand direct visits are likelier to be under-credited than paid clicks.\n\t\t\t\t\t\t{finiteOrNull(data.shownPurchases) !== null && (data.shownPurchases ?? 0) < (data.trackedPurchases ?? 0) && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{' '}The rows above account for {formatCount(data.shownPurchases ?? 0)} of those sales; the rest\n\t\t\t\t\t\t\t\tcame from sources outside this list, so this column does not add up to the whole.\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t</div>\n\n\n\t\t\t{/* WHERE THEY ARRIVED, beside where they came from.\n\t\t\t\n\t\t\t Moved from the Journey tab, where it sat only because GA4 returns it on the funnel's\n\t\t\t query. A landing page is the same grain as a referrer — one row per entry point, ranked\n\t\t\t by sessions, with the identical engagement column — so this panel now answers \"from\n\t\t\t where, and onto what\" on one screen, and the funnel tab is the funnel and its outcomes.\n\t\t\t\n\t\t\t Entries, not exits: GA4 exposes landingPage and has never had an exits metric. A\n\t\t\t previous version of this table queried one and rendered nothing, ever. */}\n\t\t\t{(data.topLandingPages?.length ?? 0) > 0 && (\n\t\t\t\t<Section\n\t\t\t\t\ttitle=\"Where sessions began\"\n\t\t\t\t\ttone=\"secondary\"\n\t\t\t\t\tsubtitle=\"The page a visit started on, busiest first.\"\n\t\t\t\t>\n\t\t\t\t\t<LandingPagesTable rows={data.topLandingPages ?? []} />\n\t\t\t\t</Section>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n/** Journey — a funnel, drawn as a tracked sequence or as independent totals, whichever the report used. */\n/**\n * How many of a segment's entrants reached the second rung.\n *\n * The single most comparable number between segments: every segment has a first step, the rate is\n * already a proportion so segments of wildly different sizes sit on one scale, and it is where a\n * foundry's funnel actually breaks. Null when the segment has fewer than two rungs or GA4 gave no\n * rate — never zero, which would rank an unmeasured segment as the worst performer.\n *\n * @param steps - one segment's rungs, in funnel order\n */\nexport function firstStepRate(steps: JourneyStep[]): number | null {\n\tconst second = steps[1]\n\tif (!second) return null\n\tconst rate = second.conversionFromPrevious\n\treturn typeof rate === 'number' && Number.isFinite(rate) ? rate : null\n}\n\n/**\n * A sentence naming the gap between the best and worst segment, or null when there is not one.\n *\n * Only fires at a MULTIPLE, not a margin. Segments always differ a little, and a tool that\n * remarks on every difference teaches the reader to stop reading its remarks; three times apart is\n * the point at which the undivided funnel above stops describing either group.\n *\n * Both sides need a real denominator. At a foundry's volumes a segment of forty users can produce\n * any rate at all, and \"tablet converts nine times better than desktop\" off six people is the kind\n * of confident nonsense this package exists not to print.\n *\n * @param segments - every segment, in any order\n */\nexport function spread(segments: JourneySegment[]): string | null {\n\tconst MIN_USERS = 200\n\tconst RATIO = 3\n\n\tconst rated = segments\n\t\t.map((segment) => ({\n\t\t\tlabel: segment.label,\n\t\t\trate: firstStepRate(segment.steps),\n\t\t\tusers: metricSortValue(segment.steps[0]?.count) ?? 0,\n\t\t}))\n\t\t.filter((row): row is { label: string; rate: number; users: number } =>\n\t\t\trow.rate !== null && row.rate > 0 && row.users >= MIN_USERS)\n\n\tif (rated.length < 2) return null\n\tconst sorted = [...rated].sort((a, b) => b.rate - a.rate)\n\tconst best = sorted[0] as { label: string; rate: number; users: number }\n\tconst worst = sorted[sorted.length - 1] as { label: string; rate: number; users: number }\n\tconst ratio = best.rate / worst.rate\n\tif (ratio < RATIO) return null\n\n\treturn `${worst.label} reaches the second step at ${formatPercent(worst.rate, 2)} against `\n\t\t+ `${best.label}'s ${formatPercent(best.rate, 2)} — about ${Math.round(ratio)} times worse, on `\n\t\t+ `${formatCount(worst.users)} visitors. The combined funnel above averages the two and describes neither.`\n}\n\n/**\n * Why a table is shorter than reality, in one line.\n *\n * Two different causes used to produce two separate notices two sentences apart: `rowsTruncated`\n * (GA4 held more rows than the query's limit returned) rendered a quiet line under the table, and\n * `rowsWithheld` (GA4 suppressed low-count rows for privacy) rendered a second amber Caveat card\n * below it. The distinction is real and worth keeping in the wording — the reader's takeaway is\n * identical either way, and the same fact in two registers two lines apart is how a reader learns\n * to stop reading amber.\n *\n * Returns undefined when the list IS complete, so nothing is said. Silence is the honest default\n * here: a note that always renders carries no information.\n *\n * @param truncated - GA4 held more rows than it returned\n * @param withheld - GA4 suppressed low-count rows for privacy\n * @param tail - what the missing rows mean for this particular table\n */\nexport function shortListNote(truncated: boolean, withheld: boolean, tail: string): string | undefined {\n\tif (!truncated && !withheld) return undefined\n\tconst cause = truncated && withheld\n\t\t? 'GA4 returned only its top rows, and withheld others with too few people to report'\n\t\t: truncated\n\t\t\t? 'GA4 returned only its top rows'\n\t\t\t: 'GA4 withheld rows with too few people to report'\n\treturn `${cause}, so this list is shorter than reality. ${tail}`\n}\n\n/**\n * The rank line under an exact figure, or nothing.\n *\n * Drawn from the daily series already in the envelope, so it costs no request and — because orders\n * and revenue come from the foundry's own records — inherits none of GA4's loss. Offered only for\n * the EXACT quantities: ranking a GA4 figure would rank the instrument's mood alongside the\n * business.\n *\n * Returns null rather than a hedge when there are too few whole weeks, so a short range simply\n * says less instead of saying something weaker.\n *\n * @param series - the cross-source daily rows\n * @param pick - which exact quantity to rank\n */\nexport function rankLine(series: CrossSourceDay[] | undefined, pick: (d: CrossSourceDay) => number | null): string | null {\n\tif (!series || series.length === 0) return null\n\tconst ranked = weeklyRank(series.map((d) => ({ date: d.date, value: pick(d) })))\n\treturn ranked ? describeRank(ranked) : null\n}\n\n\n/**\n * Whether every counted order carries an amount, so a daily revenue series is safe to draw.\n *\n * The server fills a day's revenue as `revenueByDate[date] ?? 0`, which is right when every order\n * has a total and a catastrophe when they do not: a day with a real sale and no recorded amount\n * becomes a MEASURED ZERO, and the timeline draws the tick that means \"we looked, and nothing\n * happened\" on a day something did. At Darden that is 58 of 69 days.\n *\n * So the revenue row is drawn only when the two counts agree. Orders are exact regardless, which is\n * why they are now always drawn instead.\n *\n * @param data - the measurement-health payload\n */\nexport function revenueCoversEveryOrder(data: MeasurementHealthData): boolean {\n\tconst counted = metricSortValue(data.orders)\n\tconst withTotal = finiteOrNull(data.ordersWithTotal)\n\tif (counted === null || withTotal === null) return false\n\treturn counted === withTotal\n}\n\n\nexport function JourneyPanel({ data }: { data: JourneyData }): React.ReactElement {\n\tconst segments = data.segments ?? []\n\n\t// Step order comes from the widest segment, so a segment GA4 stopped reporting partway down\n\t// leaves a gap rather than shortening the chart for everyone.\n\tconst spine = [...segments].sort((a, b) => b.steps.length - a.steps.length)[0]?.steps ?? []\n\n\t// The funnel draws EVERYONE. Per-segment shapes are in the survival chart above it, where they\n\t// can be compared side by side; a funnel that silently became one device's funnel, with only a\n\t// pressed button to say so, was the thing that made the comparison invisible.\n\tconst allSteps = data.steps ?? []\n\n\t// Steps this site does not instrument are hidden rather than drawn as empty rails. An\n\t// uninstrumented rung told the reader nothing except that the funnel had a hole in it, and it\n\t// sat between two real steps implying a drop-off that was never measured. They are counted in a\n\t// line beneath instead, so the omission is still stated but does not masquerade as a stage.\n\tconst shown = allSteps.filter((step) => step.count.status !== 'unavailable')\n\tconst hiddenSteps = allSteps.filter((step) => step.count.status === 'unavailable')\n\n\t// The chart takes plain numbers; the unavailable steps have already been removed above, so the\n\t// narrowing here cannot drop a measured value.\n\t// `partial` travels with the count. It used to stop here: the metric's status, its coveredFrom\n\t// and its note were all dropped at this line, so a step instrumented half-way through the window\n\t// reached the chart indistinguishable from one measured throughout — and the chart then printed\n\t// a share of it against a full-window denominator.\n\tconst stages = shown.flatMap((step) =>\n\t\tstep.count.status === 'unavailable'\n\t\t\t? []\n\t\t\t: [{\n\t\t\t\tkey: step.key,\n\t\t\t\tlabel: step.label,\n\t\t\t\tvalue: step.count.value,\n\t\t\t\tconversionFromPrevious: step.conversionFromPrevious,\n\t\t\t\t// The device split, subdividing this rung's bar rather than drawn as a second chart.\n\t\t\t\t// Only counts that are present and whole travel: a segment GA4 stopped reporting is\n\t\t\t\t// left out of the split rather than drawn as a shrunken share of one.\n\t\t\t\t...(segments.length > 0\n\t\t\t\t\t? {\n\t\t\t\t\t\tsegments: segments.flatMap((segment) => {\n\t\t\t\t\t\t\tconst cell = segment.steps.find((s) => s.key === step.key)\n\t\t\t\t\t\t\tconst value = cell ? metricSortValue(cell.count) : null\n\t\t\t\t\t\t\treturn value === null ? [] : [{ key: segment.key, label: segment.label, value }]\n\t\t\t\t\t\t}),\n\t\t\t\t\t}\n\t\t\t\t\t: {}),\n\t\t\t\t...(step.count.status === 'partial' && step.count.coveredFrom\n\t\t\t\t\t? { partial: { from: formatDay(step.count.coveredFrom) } }\n\t\t\t\t\t: {}),\n\t\t\t}],\n\t)\n\n\n\tconst tracked = data.measurement === 'sequence'\n\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t{/* A tracked funnel is not a caveat, so it is not drawn as one. The fallback still is:\n\t\t\t independent totals invite exactly the reading — \"this many people dropped out here\"\n\t\t\t — that they cannot support. */}\n\t\t\t<Card padding={3} radius={2} tone={tracked ? 'transparent' : 'caution'} border>\n\t\t\t\t<Stack space={2}>\n\t\t\t\t\t<Text size={1} weight=\"medium\">\n\t\t\t\t\t\t{/* \"Independent per-step totals\" was three words, none of which mean anything to\n\t\t\t\t\t\t a foundry owner, in the position a heading goes. The distinction it draws is\n\t\t\t\t\t\t real and load-bearing — it forbids reading the gaps as drop-off — so the\n\t\t\t\t\t\t wording changes and the claim does not. */}\n\t\t\t\t\t\t{tracked ? 'Tracked path' : 'Not a tracked path'}\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text size={1} muted={tracked}>{data.approximationNote}</Text>\n\t\t\t\t</Stack>\n\t\t\t</Card>\n\n\t\t\t{/* Every segment at once, instead of a control that swapped between them.\n\t\t\t\n\t\t\t The radiogroup this replaces put the most actionable fact in the data — mobile\n\t\t\t reaching the second step at a ninth of desktop's rate — behind clicking, remembering,\n\t\t\t and clicking back. A comparison the reader has to hold in their head is not a\n\t\t\t comparison the tool made. Its buttons also printed a bare rate to two decimals with\n\t\t\t no denominator and no floor: the same claim spread() refuses below 200 users,\n\t\t\t arriving through the back door with more implied precision. */}\n\t\t\t{/* ONE chart, not two. The device split now subdivides the funnel's own bars.\n\t\t\t\n\t\t\t A separate survival chart drew the same segments as normalised shares on its own axis,\n\t\t\t directly above a funnel drawing the pooled counts — two pictures of one fact. The\n\t\t\t share-only one could not print a count, and at rungs of 23, 12 and 7 the count is the\n\t\t\t only figure that survives; its denominator floor also silenced it at exactly those\n\t\t\t rungs, which are the ones about money. The funnel already prints every count, withholds\n\t\t\t every ratio it cannot support, and handles the part-window case. */}\n\n\t\t\t{segments.length >= 2 && (\n\t\t\t<ChartData\n\t\t\t\tlabel=\"Show the figures behind this\"\n\t\t\t\trows={segments}\n\t\t\t\trowKey={(segment) => segment.key}\n\t\t\t\tcolumns={[\n\t\t\t\t\t{\n\t\t\t\t\t\tkey: 'segment',\n\t\t\t\t\t\tlabel: data.segmentDimension ?? 'Segment',\n\t\t\t\t\t\tsortValue: (segment: JourneySegment) => segment.label,\n\t\t\t\t\t\trender: (segment: JourneySegment) => segment.label,\n\t\t\t\t\t},\n\t\t\t\t\t...spine.map((step) => ({\n\t\t\t\t\t\tkey: step.key,\n\t\t\t\t\t\tlabel: step.label,\n\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\tsortValue: (segment: JourneySegment) =>\n\t\t\t\t\t\t\tmetricSortValue(segment.steps.find((s) => s.key === step.key)?.count),\n\t\t\t\t\t\trender: (segment: JourneySegment) => {\n\t\t\t\t\t\t\tconst cell = segment.steps.find((s) => s.key === step.key)\n\t\t\t\t\t\t\tconst count = metricSortValue(cell?.count)\n\t\t\t\t\t\t\tif (count === null) return '—'\n\t\t\t\t\t\t\t// The rate AND its denominator. Dropping the\n\t\t\t\t\t\t\t// denominator here would reinstate the bare two-decimal rate the segment\n\t\t\t\t\t\t\t// buttons used to print — the claim spread() refuses, through the back door.\n\t\t\t\t\t\t\tconst index = spine.findIndex((sp) => sp.key === step.key)\n\t\t\t\t\t\t\tconst before = index > 0\n\t\t\t\t\t\t\t\t? metricSortValue(segment.steps.find((s) => s.key === spine[index - 1]!.key)?.count)\n\t\t\t\t\t\t\t\t: null\n\t\t\t\t\t\t\tconst rate = cell?.conversionFromPrevious ?? null\n\t\t\t\t\t\t\tconst showRate = index > 0 && rate !== null && before !== null && before >= MIN_RATE_DENOMINATOR\n\t\t\t\t\t\t\treturn showRate\n\t\t\t\t\t\t\t\t? `${formatCount(count)} · ${formatPercent(rate, 1)} of ${formatCount(before)}`\n\t\t\t\t\t\t\t\t: formatCount(count)\n\t\t\t\t\t\t},\n\t\t\t\t\t})),\n\t\t\t\t]}\n\t\t\t/>\n\t\t\t)}\n\n\t\t\t<FunnelChart stages={stages} measurement={data.measurement ?? 'independent-totals'} />\n\n\t\t\t{/* BELOW the funnel, because its own sentence ends \"the combined funnel above averages the\n\t\t\t two and describes neither\" — and it rendered above, so the copy pointed at nothing. */}\n\t\t\t{spread(segments) && <Text size={1}>{spread(segments)}</Text>}\n\n\t\t\t{(data.outcomes?.length ?? 0) > 0 && (\n\t\t\t\t<Section\n\t\t\t\t\ttitle=\"Other outcomes\"\n\t\t\t\t\t/* Beside the funnel, not inside it. An enquiry is an alternative ending, not a\n\t\t\t\t\t later stage — and until now the funnel ended at purchase, which scored the\n\t\t\t\t\t visitor who read three typeface pages and emailed as a drop-off. */\n\t\t\t\t\tsubtitle=\"Successful outcomes that are not a licence sale, counted over the same window as the funnel but not a step within it.\"\n\t\t\t\t>\n\t\t\t\t\t<div style={cardGrid}>\n\t\t\t\t\t\t{(data.outcomes ?? []).map((outcome) => (\n\t\t\t\t\t\t\t<Card key={outcome.key} padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t\t\t<Label size={1} muted>{outcome.label}</Label>\n\t\t\t\t\t\t\t\t\t<MetricFigure metric={outcome.count} label={outcome.label} size={4} />\n\t\t\t\t\t\t\t\t\t<Text size={0} muted>{outcome.note}</Text>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</Section>\n\t\t\t)}\n\n\t\t\t{hiddenSteps.length > 0 && (\n\t\t\t\t<Text size={1} muted>\n\t\t\t\t\tNot shown: {hiddenSteps.map((step) => step.label.toLowerCase()).join(', ')} — not instrumented on\n\t\t\t\t\tthis site, so there is no figure to place in the funnel.\n\t\t\t\t</Text>\n\t\t\t)}\n\n\t\t</div>\n\t)\n}\n\n/**\n * Landing pages, as a table.\n *\n * Lifted out of `JourneyPanel` so the two supporting blocks on that tab can sit in one grid without\n * a hundred lines of column definitions between them. Behaviour is unchanged.\n *\n * @param rows - landing pages, busiest first\n */\nfunction LandingPagesTable({ rows }: { rows: LandingPage[] }): React.ReactElement {\n\treturn (\n\t\t<SortableTable<LandingPage>\n\t\t\t\t\t\tcaption=\"Landing pages by sessions\"\n\t\t\t\t\t\tinitialSort=\"sessions\"\n\t\t\t\t\t\trows={rows}\n\t\t\t\t\t\trowKey={(page) => page.path}\n\t\t\t\t\t\tfilterOn={(page) => page.path}\n\t\t\t\t\t\tfilterPlaceholder=\"Filter pages\"\n\t\t\t\t\t\texportName=\"landing-pages\"\n\t\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'path',\n\t\t\t\t\t\t\t\tlabel: 'Page',\n\t\t\t\t\t\t\t\tsortValue: (page) => page.path,\n\t\t\t\t\t\t\t\t// The comment here used to describe this fix without applying it: it said\n\t\t\t\t\t\t\t\t// the foundry's own URLs were the one table that did not link while\n\t\t\t\t\t\t\t\t// referrer hosts did, and then rendered plain text. A comment asserting\n\t\t\t\t\t\t\t\t// something the code does not do is worse than no comment.\n\t\t\t\t\t\t\t\trender: (page) => (\n\t\t\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\t\t\t<a href={page.path} target=\"_blank\" rel=\"noopener noreferrer\" style={sourceLink}>{page.path}</a>\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'sessions',\n\t\t\t\t\t\t\t\tlabel: 'Sessions',\n\t\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t\tsortValue: (page) => page.sessions,\n\t\t\t\t\t\t\t\trender: (page) => <Text size={1}>{formatCount(page.sessions)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tkey: 'engagement',\n\t\t\t\t\t\t\t\tlabel: 'Engaged',\n\t\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t\tsortValue: (page) => finiteOrNull(page.engagementRate),\n\t\t\t\t\t\t\t\texportValue: (page) => { const r = finiteOrNull(page.engagementRate); return r === null ? null : formatPercent(r, 0) },\n\t\t\t\t\t\t\t\t// Volume alone could not distinguish a page that delivers 200 arrivals\n\t\t\t\t\t\t\t\t// which leave from one that feeds the shop.\n\t\t\t\t\t\t\t\trender: (page) => finiteOrNull(page.engagementRate) === null\n\t\t\t\t\t\t\t\t\t? <Text size={1} muted>—</Text>\n\t\t\t\t\t\t\t\t\t: <Text size={1}>{formatPercent(page.engagementRate as number, 0)}</Text>,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\t)\n}\n\n/**\n * The catalogue's own orders-per-view: every family's sales over every family's views.\n *\n * The benchmark was the MEDIAN FAMILY's rate, and that was wrong twice over. It filtered to rates\n * above zero, so a family viewed four hundred times that has never sold — the single most useful\n * row, and the one the column was built to surface — was excluded from the benchmark AND rendered\n * as a dash. And at seven orders a quarter more than half the catalogue sells nothing in a window,\n * so the median is zero and an index against it is undefined for everyone.\n *\n * A pooled rate has neither problem: it is positive whenever anything sold at all, a family with no\n * sales scores an honest zero against it, and it is the benchmark the reader would name themselves\n * — \"against how the catalogue as a whole converts\".\n *\n * Returns null when nothing sold or nothing was viewed, because there is then no catalogue rate to\n * compare against and an index would be invented.\n *\n * @param rows - every family in the window\n */\n/**\n * Views a family needs before its sales-per-view is comparable to anything.\n *\n * Thirty, matching the funnel's floor: below it one more order moves the index by more than a\n * third, so the figure describes a single buyer rather than how the family converts.\n */\nconst MIN_FAMILY_VIEWS = 30\n\nexport function catalogueRate(rows: Array<{ viewed?: MetricValue; bought?: MetricValue }>): number | null {\n\tlet views = 0\n\tlet sales = 0\n\tfor (const row of rows) {\n\t\t// Whole-window views only. `metricSortValue` returns the value for `partial` as well as `ok`,\n\t\t// so a family whose view count covers thirteen days of a thirty-one-day range was pooled with\n\t\t// families covering all of it — and the benchmark every OTHER family is measured against was\n\t\t// itself computed across a mixture of windows.\n\t\tif (row.viewed?.status !== 'ok') continue\n\t\tconst viewed = metricSortValue(row.viewed)\n\t\tconst bought = metricSortValue(row.bought)\n\t\t// Both sides or neither: a family GA4 has no view count for cannot contribute its sales to a\n\t\t// rate, or the pooled figure would be sales from families whose views are missing.\n\t\tif (viewed === null || bought === null) continue\n\t\tviews += viewed\n\t\tsales += bought\n\t}\n\treturn views > 0 && sales > 0 ? sales / views : null\n}\n\n/**\n * A family's orders-per-view against the catalogue's, or null when not comparable.\n *\n * Zero is a RESULT, not an absence: a family that was viewed and never bought scores 0 and belongs\n * at the bottom of the sort, which is where a reader looking for a pricing or specimen problem\n * starts. Only a family with no usable rate at all returns null.\n *\n * @param row - the family's row\n * @param benchmark - the catalogue rate, or null when there was none\n */\nexport function buyRateIndex(\n\trow: { buyRate?: number | null; viewed?: MetricValue },\n\tbenchmark: number | null,\n): number | null {\n\tconst rate = row.buyRate\n\tif (benchmark === null || benchmark <= 0) return null\n\tif (rate === null || rate === undefined || !Number.isFinite(rate) || rate < 0) return null\n\n\t/*\n\t * The family's own views have to carry a ratio, not just the catalogue's.\n\t *\n\t * Every other small-sample figure here is gated by a number — the funnel withholds a rate below\n\t * thirty and says \"too few to give a rate\", the revenue split needs five attributed purchases and\n\t * a quarter of the order book. This column was gated only by a sentence in the blurb. On a\n\t * catalogue where a family's month is sixteen views and zero-or-one order, a single sale printed\n\t * \"4.9× catalogue\" in the same type as a figure computed on hundreds, and a reader would put that\n\t * typeface on the homepage on the strength of one order.\n\t *\n\t * The same floor the funnel uses, applied to the denominator that actually varies per row.\n\t */\n\tconst views = metricSortValue(row.viewed)\n\tif (views === null || views < MIN_FAMILY_VIEWS) return null\n\n\treturn rate / benchmark\n}\n\n/**\n * Revenue divided by the orders that actually carried an amount.\n *\n * NOT by the full order count. At Darden 58 of 69 counted orders have no amount recorded, so\n * dividing the revenue sum by every order would report an average six times too low — a number\n * someone might price against. The denominator has to be the same population as the numerator.\n *\n * Inherits `partial` from the revenue figure, because an average over a sixth of the orders is an\n * average of that sixth and should not present as the catalogue's.\n *\n * @param data - the overview payload\n */\n/**\n * What a campaign's following days took, per thousand addresses it went to.\n *\n * Per thousand rather than per send because a foundry mails thousands against a handful of orders:\n * divided per send the figure is fractions of a cent and every campaign renders as the same zero.\n *\n * Null — never zero — when there is no revenue figure, no send count, or the window has not run,\n * so an unmeasured campaign is not ranked below one that genuinely sold nothing.\n *\n * @param campaign - one send, already carrying its following-days figures\n */\nexport function revenuePerThousandSent(campaign: EmailCampaign): number | null {\n\tif (campaign.revenueAfter === null || campaign.revenueAfter === undefined) return null\n\tif (!Number.isFinite(campaign.sent) || campaign.sent <= 0) return null\n\treturn (campaign.revenueAfter / campaign.sent) * 1000\n}\n\nexport function averageOrderValue(data: MeasurementHealthData): MetricValue {\n\t/*\n\t * A derived figure INHERITS the absence of what it was derived from.\n\t *\n\t * Both of these first returned `not_applicable` for any missing input — which on a site whose\n\t * route predates the fields renders \"Does not apply to this site\" over a dash, the precise\n\t * contradiction removed from `metricOr` two releases ago, reintroduced one layer up. The\n\t * version-skew test caught it, which is what that test is for. `metricOr` already knows how to\n\t * describe a field the route is too old to send; a derived figure should say the same thing.\n\t */\n\tconst revenueMetric = metricOr(data.revenue, OLDER_ROUTE)\n\tif (revenueMetric.status === 'unavailable') return revenueMetric\n\tconst revenue = metricSortValue(revenueMetric)\n\tconst orders = finiteOrNull(data.ordersWithTotal)\n\tif (revenue === null || orders === null || orders <= 0) {\n\t\treturn unavailable('not_applicable', 'No order in this range carried an amount to average')\n\t}\n\tconst average = revenue / orders\n\t// Partial is decided here rather than inherited from the revenue metric, because this figure has\n\t// its own denominator: whenever fewer orders carry an amount than were counted, the average is\n\t// over a subset and the reader has to be told which subset before comparing it to anything.\n\tconst counted = finiteOrNull(metricSortValue(metricOr(data.orders, OLDER_ROUTE)))\n\tconst overSubset = counted !== null && counted > orders\n\treturn overSubset || data.revenue.status === 'partial'\n\t\t? partial(average, '', `Averaged over the ${formatCount(orders)} of ${formatCount(counted ?? orders)} orders that carry an amount.`)\n\t\t: ok(average)\n}\n\n/**\n * How many visitors it takes to make one sale.\n *\n * Against VERCEL's visitors rather than GA4's sessions. The owner's instinct is orders-per-session,\n * but GA4 sees a fraction of the traffic here, so that ratio would flatter conversion by whatever\n * share is missing — the same mistake the buy-rate column was rebuilt to avoid. Vercel is the least\n * lossy denominator available, and it counts people rather than visits, which is the unit a sale\n * belongs to.\n *\n * @param data - the overview payload\n */\nexport function visitorsPerOrder(data: MeasurementHealthData): MetricValue {\n\tconst visitorMetric = metricOr(data.vercelVisitors, OLDER_ROUTE)\n\tconst orderMetric = metricOr(data.orders, OLDER_ROUTE)\n\tif (visitorMetric.status === 'unavailable') return visitorMetric\n\tif (orderMetric.status === 'unavailable') return orderMetric\n\tconst visitors = metricSortValue(visitorMetric)\n\tconst orders = metricSortValue(orderMetric)\n\tif (visitors === null || orders === null || orders <= 0) {\n\t\treturn unavailable('not_applicable', 'No order in this range to divide the visitors by')\n\t}\n\treturn ok(Math.round(visitors / orders))\n}\n\n/** Typeface interest — viewed, tested and bought per family. */\nexport function TypefaceInterestPanel({ data }: { data: TypefaceInterestData }): React.ReactElement {\n\t// The whole catalogue's orders-per-view, which every family is measured against.\n\tconst benchmark = catalogueRate(data.rows ?? [])\n\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t<Card padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t<Text size={1} muted>{data.interpretationNote}</Text>\n\t\t\t</Card>\n\n\t\t\t<div style={stackBlock}>\n\t\t\t\t<SectionTitle title=\"Engagement by typeface\" />\n\t\t\t\t<Text size={1} muted>\n\t\t\t\t\tSort by any column. “Sells vs catalogue” compares each family’s sales-per-view\n\t\t\t\t\tagainst the catalogue as a whole — 1.0× is average, and sorting up finds the families that\n\t\t\t\t\tare looked at and do not sell. It is a comparison between your families, not the share of\n\t\t\t\t\tvisitors who buy: Google Analytics sees only a fraction of the views, which moves every\n\t\t\t\t\tfamily together and so cancels out here. With one or two sales a family will still swing a\n\t\t\t\t\tlong way, so read it alongside the order counts beside it.\n\t\t\t\t\t{' '}Bought and Revenue come from your own orders, not from Google Analytics — do not scale\n\t\t\t\t\tthose up.\n\t\t\t\t\t{' '}A family needs a reasonable number of views before it gets a comparison at all;\n\t\t\t\t\tbelow that it says so rather than ranking one order against the catalogue.\n\t\t\t\t</Text>\n\t\t\t\t<SortableTable<TypefaceInterestRow>\n\t\t\t\t\tcaption=\"Engagement by typeface\"\n\t\t\t\t\tinitialSort=\"viewed\"\n\t\t\t\t\trows={data.rows ?? []}\n\t\t\t\t\trowKey={(row) => row.typeface}\n\t\t\t\t\tfilterOn={(row) => row.typeface}\n\t\t\t\t\tfilterPlaceholder=\"Filter typefaces\"\n\t\t\t\t\texportName=\"typeface-interest\"\n\t\t\t\t\ttruncatedNote={shortListNote(\n\t\t\t\t\t\tBoolean(data.rowsTruncated),\n\t\t\t\t\t\tBoolean(data.rowsWithheld),\n\t\t\t\t\t\t'A family missing from this table has not necessarily gone quiet — and for a foundry the long tail is most of the catalogue.',\n\t\t\t\t\t)}\n\t\t\t\t\tcolumns={[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'typeface',\n\t\t\t\t\t\t\tlabel: 'Typeface',\n\t\t\t\t\t\t\tsortValue: (row) => row.typeface,\n\t\t\t\t\t\t\trender: (row) => <Text size={1}>{row.typeface}</Text>,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'viewed',\n\t\t\t\t\t\t\t// The source is in the HEADER, because the ribbon at the top of this tab\n\t\t\t\t\t\t\t// instructs the reader to multiply its figures by about five — and two of\n\t\t\t\t\t\t\t// these four columns come from the order book and are exact. Following that\n\t\t\t\t\t\t\t// instruction across the whole table multiplies real revenue fivefold. The\n\t\t\t\t\t\t\t// sentence that prevents it already exists on the licence block below; it was\n\t\t\t\t\t\t\t// simply never applied where the mistake is made.\n\t\t\t\t\t\t\tlabel: 'Viewed (GA4)',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => metricSortValue(row.viewed),\n\t\t\t\t\t\t\trender: (row) => <MetricFigure metric={row.viewed} label={`${row.typeface} viewed`} size={1} />,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'tested',\n\t\t\t\t\t\t\tlabel: 'Tested (GA4)',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => metricSortValue(row.tested),\n\t\t\t\t\t\t\trender: (row) => <MetricFigure metric={row.tested} label={`${row.typeface} tested`} size={1} />,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'bought',\n\t\t\t\t\t\t\t// Never folded. Zero sales is the finding, not an empty column.\n\t\t\t\t\t\t\talwaysShow: true,\n\t\t\t\t\t\t\tlabel: 'Bought (orders)',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => metricSortValue(row.bought),\n\t\t\t\t\t\t\trender: (row) => <MetricFigure metric={row.bought} label={`${row.typeface} bought`} size={1} />,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey: 'revenue',\n\t\t\t\t\t\t\t// Same reason as Bought: an exact zero from the order book is a measurement.\n\t\t\t\t\t\t\talwaysShow: true,\n\t\t\t\t\t\t\tlabel: 'Revenue (orders)',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\tsortValue: (row) => metricSortValue(row.revenue),\n\t\t\t\t\t\t\texportValue: (row) => {\n\t\t\t\t\t\t\t\tconst value = metricSortValue(row.revenue)\n\t\t\t\t\t\t\t\treturn value === null ? null : formatMoney(value, data.currency ?? null)\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t// The column that lets a catalogue be ranked by what it is worth rather\n\t\t\t\t\t\t\t// than by unit count, where a $30 web licence and a $400 desktop family\n\t\t\t\t\t\t\t// were the same integer.\n\t\t\t\t\t\t\t// Only a plain `ok` takes the bare-text branch. A `partial` value kept its\n\t\t\t\t\t\t\t// number but lost the caution badge and the note explaining what it\n\t\t\t\t\t\t\t// covers, which every other numeric column in this table keeps.\n\t\t\t\t\t\t\trender: (row) => {\n\t\t\t\t\t\t\t\tconst revenue = metricOr(row.revenue, OLDER_ROUTE)\n\t\t\t\t\t\t\t\treturn revenue.status === 'ok'\n\t\t\t\t\t\t\t\t\t? <Text size={1}>{formatMoney(revenue.value, data.currency ?? null)}</Text>\n\t\t\t\t\t\t\t\t\t: <MetricFigure metric={revenue} label={`${row.typeface} revenue`} size={1} />\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t * Relative to the catalogue, NOT an absolute rate.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * This is Sanity orders over GA4 views: a complete numerator on a lossy\n\t\t\t\t\t\t\t * denominator. Printed as \"0.85%\" it invited the reading \"eight in a\n\t\t\t\t\t\t\t * thousand visitors buy this\", which is wrong by whatever multiple GA4 is\n\t\t\t\t\t\t\t * missing — and it MOVES when the tag breaks. When Darden's GA4 count fell\n\t\t\t\t\t\t\t * from 471 a day to 70, every family's printed rate multiplied by about\n\t\t\t\t\t\t\t * seven, and the families that looked best were the ones GA4 had stopped\n\t\t\t\t\t\t\t * seeing.\n\t\t\t\t\t\t\t *\n\t\t\t\t\t\t\t * What survives that is the ORDER. The loss is roughly uniform across\n\t\t\t\t\t\t\t * families, so it cancels in a comparison between them even as it wrecks\n\t\t\t\t\t\t\t * the absolute figure. Showing each family against the catalogue median\n\t\t\t\t\t\t\t * keeps the one question this column can answer — which families convert\n\t\t\t\t\t\t\t * better or worse than the rest — and drops the one it cannot.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tkey: 'buyRate',\n\t\t\t\t\t\t\tlabel: 'Sells vs catalogue',\n\t\t\t\t\t\t\tnumeric: true,\n\t\t\t\t\t\t\t// The INDEX, so a row rendered as a dash cannot sort above a row with a value.\n\t\t\t\t\t\t\t// Sorting on the raw rate returned 0 — a real number — for a family whose\n\t\t\t\t\t\t\t// cell showed \"—\", so ascending put a block of dashes at the top of the\n\t\t\t\t\t\t\t// table, which is the one thing this file's header forbids.\n\t\t\t\t\t\t\tsortValue: (row) => buyRateIndex(row, benchmark),\n\t\t\t\t\t\t\texportValue: (row) => {\n\t\t\t\t\t\t\t\tconst index = buyRateIndex(row, benchmark)\n\t\t\t\t\t\t\t\treturn index === null ? null : `${index.toFixed(1)}x catalogue`\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\trender: (row) => {\n\t\t\t\t\t\t\t\tconst index = buyRateIndex(row, benchmark)\n\t\t\t\t\t\t\t\tif (index === null) {\n\t\t\t\t\t\t\t\t\t// Says which absence it is, like the funnel does. A dash alone made a\n\t\t\t\t\t\t\t\t\t// family too quiet to compare look the same as one the catalogue could\n\t\t\t\t\t\t\t\t\t// not be benchmarked against at all.\n\t\t\t\t\t\t\t\t\tconst views = metricSortValue(row.viewed)\n\t\t\t\t\t\t\t\t\tconst tooQuiet = views !== null && views < MIN_FAMILY_VIEWS\n\t\t\t\t\t\t\t\t\t// Three absences, not two. A part-window view count is neither \"too quiet\"\n\t\t\t\t\t\t\t\t\t// nor simply missing — it is a real number that cannot be a denominator\n\t\t\t\t\t\t\t\t\t// for orders read across the whole range.\n\t\t\t\t\t\t\t\t\tconst partWindow = row.viewed?.status === 'partial'\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<Text size={1} muted aria-label={`${row.typeface} not comparable`}>\n\t\t\t\t\t\t\t\t\t\t\t{partWindow\n\t\t\t\t\t\t\t\t\t\t\t\t? 'view tracking started mid-period'\n\t\t\t\t\t\t\t\t\t\t\t\t: tooQuiet ? 'too few views to compare' : '—'}\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif (index === 0) {\n\t\t\t\t\t\t\t\t\t// Named, not left as \"0.0×\". A family with views and no sales is the\n\t\t\t\t\t\t\t\t\t// row this column exists to surface.\n\t\t\t\t\t\t\t\t\treturn <Text size={1}>No sales</Text>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<Text size={1}>\n\t\t\t\t\t\t\t\t\t\t{index.toFixed(1)}× catalogue\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t// No Test rate column. It was tested ÷ viewed, rendered muted, from the two\n\t\t\t\t\t\t// columns immediately to its left — and typefaceInterest's own note says that\n\t\t\t\t\t\t// where a site maps several tester events onto the step (TDF names three) the\n\t\t\t\t\t\t// numerator is not a person count, so the ratio ranks page layout as much as\n\t\t\t\t\t\t// interest. A reader who wants it can see both terms side by side.\n\t\t\t\t\t]}\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t{(data.licences?.length ?? 0) > 0 && (\n\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t<SectionTitle title=\"How licences sell\" />\n\t\t\t\t\t<Text size={1} muted>\n\t\t\t\t\t\tFrom your orders, so exact. Bars are orders; the money beside them is that order\n\t\t\t\t\t\ttotal split evenly across the licences on it, so it is a share rather than a price.\n\t\t\t\t\t</Text>\n\t\t\t\t\t{/* FACETED BY TYPE, RUNGS IN LADDER ORDER.\n\t\t\t\t\t\n\t\t\t\t\t This was one ranked list of `type · tier` strings with term as a subtitle — which\n\t\t\t\t\t flattened a three-dimensional fact into one axis and then sorted that axis by\n\t\t\t\t\t VALUE, shredding the only ordering tier has. Tier is a price ladder; the shape a\n\t\t\t\t\t foundry reads off it is whether the money sits at the bottom rung or the top, and\n\t\t\t\t\t a sort by size cannot produce that shape at any zoom level.\n\t\t\t\t\t\n\t\t\t\t\t Worse for the question the caption asks: \"a tier that sells well at one year may\n\t\t\t\t\t not at perpetual\" put the two rows for one tier as far apart as their values\n\t\t\t\t\t happened to fall. They are adjacent now, which is the comparison.\n\t\t\t\t\t\n\t\t\t\t\t Bars share one scale across every facet, so a rung in Desktop is comparable with a\n\t\t\t\t\t rung in Web. */}\n\t\t\t\t\t<LicenceLadder rows={data.licences ?? []} currency={data.currency ?? null} />\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{/* Both completeness flags were computed by the server and drawn nowhere, so a table\n\t\t\t holding part of the catalogue was presented as the catalogue. */}\n\n\t\t\t{/* One apportionment note for the panel, not one per section. The licence table and the\n\t\t\t family table were each printing a near-identical sentence about even splitting,\n\t\t\t ~28 words apart, which read as a stutter and made the other notes look cheaper. */}\n\t\t\t{/* One block, not three sections. These are three caveats about the same revenue\n\t\t\t column; as direct children of the panel root each was separated by a full section\n\t\t\t break, so thirty pixels of type occupied a third of a screen and read as three\n\t\t\t regions. */}\n\t\t\t<div style={stackBlock}>\n\t\t\t\t{data.revenueIsApportioned && (\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t{/* \"Apportioned\" was the stall, and it was not even the risk — EVENLY is. A foundry\n\t\t\t\t\t\t that sold two weights on one invoice sees half the money against each, which is\n\t\t\t\t\t\t not how they booked it. */}\n\t\t\t\t\t\tAn order covering several families is split evenly between them, because the order\n\t\t\t\t\t\tdocuments carry no per-line value.\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t\t{/* Why the column sums short. `orders.ts` computes this figure with a comment saying it\n\t\t\t\t exists to stop the column summing to less than the range's revenue with nothing to\n\t\t\t\t explain it — and then it was surfaced nowhere. */}\n\t\t\t\t{finiteOrNull(data.unattributedRevenue) !== null && (data.unattributedRevenue as number) > 0 && (\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t{formatMoney(data.unattributedRevenue as number, data.currency ?? null)} of this range’s\n\t\t\t\t\t\ttakings is not in the column above — those orders name no family in this catalogue.\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t\t{/* And how much of the order book the column covers at all. The same money is reported as\n\t\t\t\t partial on Overview; this tab used to call it exact. */}\n\t\t\t\t{finiteOrNull(data.ordersMissingTotal) !== null && (data.ordersMissingTotal as number) > 0 && (\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t{formatCount(data.ordersMissingTotal as number)} order\n\t\t\t\t\t\t{data.ordersMissingTotal === 1 ? '' : 's'} in this range carry no amount, so the revenue\n\t\t\t\t\t\tcolumn does not cover {data.ordersMissingTotal === 1 ? 'it' : 'them'}.\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\n/** Sanity UI tone for each check status. Status is also always spelled out in text. */\nconst CHECK_TONE: Record<CheckStatus, 'positive' | 'caution' | 'critical' | 'default'> = {\n\tpass: 'positive',\n\twarn: 'caution',\n\tfail: 'critical',\n\tskipped: 'default',\n}\n\n/** Word shown alongside the tone, so status never depends on colour alone. */\nconst CHECK_WORD: Record<CheckStatus, string> = {\n\tpass: 'Pass',\n\twarn: 'Check',\n\tfail: 'Fail',\n\tskipped: 'Skipped',\n}\n\n/**\n * Diagnostics — what to fix before trusting anything else here.\n *\n * Deliberately the panel that still works with nothing configured: on a site without credentials\n * it is the only one that can say something useful, and it is the first thing worth opening once\n * credentials land.\n */\nexport function DiagnosticsPanel({ data }: { data: DiagnosticReport }): React.ReactElement {\n\t// Guarded like every other array the panels read. This one was missed in the pass that made\n\t// the rest skew-tolerant, and it is what crashed the Diagnostics tab: `checks` is absent from\n\t// an older route's response, and `undefined.filter` takes the whole tool down.\n\tconst checks = data.checks ?? []\n\tconst failing = checks.filter((c) => c.status === 'fail').length\n\tconst warning = checks.filter((c) => c.status === 'warn').length\n\n\t// Scoped to what these checks actually test. The previous wording — \"the figures in the other\n\t// panels can be taken at face value\" — was a blanket endorsement covering statistical validity,\n\t// sampling, small denominators and a funnel the tool itself calls an approximation. These are\n\t// plumbing checks. A pass means the wiring is sound, not that the numbers are.\n\tconst summary =\n\t\tchecks.length === 0\n\t\t\t? 'No checks ran. Nothing here has been verified either way.'\n\t\t\t: data.verdict === 'pass'\n\t\t\t\t? 'Configuration and credentials check out. That covers the wiring, not whether the figures are worth trusting — the caveats on each panel still apply.'\n\t\t\t\t// A verdict of `skipped` is not a failure. The server-side severity order was fixed\n\t\t\t\t// so an all-skipped run stops reporting as a problem, but this branch still fell\n\t\t\t\t// through to the failure sentence and printed \"0 failing, 0 worth a look. Panels\n\t\t\t\t// depending on these will be wrong or incomplete\" — alarming, and false. Checks skip\n\t\t\t\t// routinely: no Vercel project, no purchases in the window, no orders in thirty days.\n\t\t\t\t: failing === 0 && warning === 0\n\t\t\t\t\t? `Nothing is failing. ${checks.length - failing - warning} check${checks.length - failing - warning === 1 ? '' : 's'} could not run in this range — usually because there was nothing to check, not because something is wrong.`\n\t\t\t\t\t: `${failing} failing, ${warning} worth a look. Panels depending on these will be wrong or incomplete until they are resolved.`\n\n\treturn (\n\t\t<div style={panelStack}>\n\t\t\t<Card padding={3} radius={2} tone={CHECK_TONE[data.verdict] ?? 'default'} border>\n\t\t\t\t<Text size={1}>{summary}</Text>\n\t\t\t</Card>\n\n\t\t\t<div style={stackBlock}>\n\t\t\t\t{checks.map((item) => (\n\t\t\t\t\t<Card key={item.id} padding={3} radius={2} tone=\"transparent\">\n\t\t\t\t\t\t<div style={stackBlock}>\n\t\t\t\t\t\t\t<Flex align=\"center\" justify=\"space-between\" gap={3}>\n\t\t\t\t\t\t\t\t<Text size={1} weight=\"semibold\">{item.label}</Text>\n\t\t\t\t\t\t\t\t<Badge tone={CHECK_TONE[item.status] ?? 'default'} fontSize={0}>{CHECK_WORD[item.status] ?? item.status}</Badge>\n\t\t\t\t\t\t\t</Flex>\n\t\t\t\t\t\t\t<Text size={1} muted>{item.detail}</Text>\n\t\t\t\t\t\t\t{item.remedy && <Text size={1}>{item.remedy}</Text>}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Card>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n","/**\n * One time axis, every source stacked against it.\n *\n * This is the only view in the tool that answers a question no single source can: did the thing we\n * did move the thing we care about. A campaign goes out on Tuesday — did traffic rise, did revenue\n * follow, and did GA4 even see it. Vercel knows the traffic, Sanity knows the money, Mailchimp\n * knows the send date, and GA4 knows a lossy fraction of the behaviour in between.\n *\n * SMALL MULTIPLES, NOT A DUAL AXIS. Putting pageviews and revenue on one pair of axes requires\n * choosing a scale factor between them, and whatever is chosen manufactures a visual correlation\n * that the data did not claim — two lines can be made to cross, diverge or track by nothing more\n * than the ratio picked. Stacked rows sharing one x axis show the same co-movement and assert\n * nothing about relative magnitude, because each row carries its own y axis and its own units.\n *\n * COMPLETENESS IS DRAWN. A series from a source that misses things is dashed and carries a shaded\n * band up to its estimated true value; a complete source is a solid line. The reader learns which\n * numbers are facts and which are a fifth of the facts without being told twice.\n *\n * d3 is used for scales and path generation only — pure functions in, path strings out. No\n * d3-selection, so nothing here touches the DOM and the whole chart renders under\n * renderToStaticMarkup, which is how the tests exercise it.\n */\n\nimport React, { useCallback, useMemo, useRef, useState } from 'react'\nimport { Card, Stack, Text } from '@liiift-studio/sanity-ui-compat'\nimport { scaleUtc, scaleLinear } from 'd3-scale'\nimport { line as d3Line, area as d3Area, curveLinear } from 'd3-shape'\nimport { max as d3Max } from 'd3-array'\nimport { HoverCard, SPACE, formatCount, formatMoney, formatPercent } from './Figure'\n// Lives in Figure.tsx, which is where the shared drawing components belong, and is re-exported\n// here because this is where it was born and where its tests still import it from.\nexport { HoverCard } from './Figure'\nimport { MARKS, SERIES, mark, seriesFill, type SeriesKey } from './palette'\n\n/** One point on one series. `value` null where the source reported nothing for that day. */\nexport interface SeriesPoint {\n\tdate: string\n\tvalue: number | null\n}\n\n/** How a value should be written out. */\nexport type SeriesUnit = 'count' | 'money' | 'percent'\n\n/**\n * One row of the chart: one answer, with what a lossier source saw underneath it.\n *\n * A row shows a SINGLE line by default. Two peer lines make the reader reconcile before they get\n * an answer, and at a glance a foundry owner wants \"how much\", not \"here are two measurements that\n * disagree\". The disagreement is still drawn — as a filled region, and in full on hover — because\n * hiding it entirely would switch off the alarm: the 24 August collapse was visible precisely\n * because two lines came apart.\n */\n/**\n * The colour a row draws in.\n *\n * Explicit `color` wins; otherwise the upstream decides, so every row from one source reads as a\n * family and a caller adding a row cannot accidentally introduce a sixth hue. Vercel is the\n * complete count and gets the reference blue; GA4's two units share the warm pair; Sanity's\n * quantities split into orders and money by unit, because a foundry reads those as different\n * things even though they come from one place.\n *\n * @param source - the upstream the line came from\n * @param unit - what the row measures, which separates Sanity's two\n * @param explicit - a colour the caller named\n */\nexport function colorFor(source: Series['source'], unit: SeriesUnit, explicit?: SeriesKey): SeriesKey {\n\tif (explicit) return explicit\n\tif (source === 'Vercel') return 'vercel'\n\tif (source === 'Sanity') return unit === 'money' ? 'revenue' : 'orders'\n\t// GA4 and Mailchimp. Sessions are the unit GA4 reports natively; a percentage computed from it\n\t// keeps the same family.\n\treturn unit === 'count' ? 'ga4Pageviews' : 'ga4Sessions'\n}\n\n/**\n * The floating readout, anchored to the hovered day.\n *\n * Sits over the plot rather than under it. The values used to live in a row beneath the chart, so\n * reading a peak meant looking at the peak, then travelling to the bottom of the component, then\n * back — and on a tall stack of small multiples the two were far enough apart that the reader lost\n * which day they were on. Anchored to the crosshair, the number is where the eye already is.\n *\n * `pointer-events: none` throughout: the card follows the pointer across the plot and must never\n * become the thing under it, which would end the hover it exists to describe and flicker.\n *\n * It flips to the left of the crosshair in the right-hand third, so the last week of a range — the\n * part a reader looks at most — does not push the card off the pane.\n */\nexport interface Series {\n\tkey: string\n\tlabel: string\n\t/** Which upstream the LINE came from. */\n\tsource: 'GA4' | 'Vercel' | 'Sanity' | 'Mailchimp'\n\t/** Whether the line's source sees everything. Drives the stroke and the wording. */\n\tcomplete: boolean\n\t/**\n\t * Which series colour this row draws in.\n\t *\n\t * Optional, and falling back to the row's `source`, so a caller that adds a row gets a colour\n\t * consistent with every other row from the same upstream rather than an uncoloured one. Set it\n\t * explicitly only where the source does not decide the meaning — coverage is computed FROM GA4\n\t * and Vercel and belongs to neither.\n\t */\n\tcolor?: SeriesKey\n\tunit: SeriesUnit\n\t/**\n\t * How the row is drawn. Defaults to `line`.\n\t *\n\t * `events` is for a quantity that only exists on the days it happened — orders, revenue. Darden\n\t * does seven orders a quarter, and a monotone curve through those seven points drew a smooth\n\t * rise and fall across eighty-three days on which nothing occurred, with the curve's overshoot\n\t * putting non-zero revenue on days that had none. A stem per day says what a sale is: a discrete\n\t * event, on a date, of a size. Days with nothing draw nothing, which is the truth.\n\t */\n\tmark?: 'line' | 'events'\n\t/**\n\t * A fixed y domain, instead of scaling the row to its own peak.\n\t *\n\t * Small multiples normally scale each row to itself, which is right for quantities whose\n\t * absolute size is not comparable between rows. It is wrong for a proportion: a coverage row\n\t * auto-scaled to its own maximum would redraw 100% at whatever the best day happened to be, so\n\t * a site running at a flat 20% would show a full-height line and read as healthy.\n\t */\n\tdomain?: [number, number]\n\t/**\n\t * The same series in the previous equivalent window, aligned by day OFFSET rather than by date.\n\t *\n\t * Drawn as a quiet ghost behind the row. Without it every row is an unanchored silhouette: a\n\t * flat line at 60 and a flat line at 6,000 are the same picture, and \"traffic looks lumpy\" is an\n\t * impression nobody can falsify. The comparison envelope is already fetched and already handed\n\t * to the metric cards, so this costs a prop and no network.\n\t *\n\t * What it buys is bigger than a reference line. It turns the tool's most fragile asset — a dated\n\t * alarm produced by a heuristic with a 21-day floor and a percentile threshold — from something\n\t * the reader has to trust into something they can check: the coverage row's step reads against\n\t * last period's flat line whether or not the detector fired.\n\t */\n\tcomparison?: SeriesPoint[]\n\tpoints: SeriesPoint[]\n\t/**\n\t * What a lossier source saw of the same thing.\n\t *\n\t * Drawn as a filled region between the two, NOT as a symmetric uncertainty band. There is no\n\t * doubt about the traffic here: Vercel counted it server-side. What is uncertain is how much of\n\t * it the analytics could see, and the honest way to draw that is the area it missed — a\n\t * quantity, visible to scale, rather than a percentage on another tab.\n\t */\n\tshortfall?: {\n\t\tlabel: string\n\t\t/** The lossier source's colour. Falls back to its `source`, as the row's own does. */\n\t\tcolor?: SeriesKey\n\t\tsource: Series['source']\n\t\tpoints: SeriesPoint[]\n\t}\n}\n\n/** A dated event drawn through every row, e.g. a campaign send. */\nexport interface TimelineMarker {\n\tdate: string\n\tlabel: string\n\tdetail?: string\n}\n\n/** Props for CrossSourceTimeline. */\nexport interface CrossSourceTimelineProps {\n\tseries: Series[]\n\tmarkers?: TimelineMarker[]\n\t/** ISO 4217 code for any `money` series. */\n\tcurrency?: string | null\n\t/**\n\t * Called when the reader drags across a span, with inclusive ISO dates.\n\t *\n\t * Time is the only dimension all four sources genuinely share, which makes it the only thing\n\t * worth linking on — a referrer row carries no date and a funnel step carries no source, so\n\t * value-based cross-filtering would need dimensions the reports do not request and could not\n\t * afford. Dragging a span and having every panel reflow to it is the whole of the cross-filter\n\t * this data model supports, and it costs one callback: the range state already drives\n\t * everything.\n\t */\n\tonBrush?: (start: string, end: string) => void\n}\n\n/**\n * Design width, in the same units as every other constant here.\n *\n * The chart previously used a `0 0 100 H` viewBox with `preserveAspectRatio=\"none\"` and a CSS\n * height equal to H — so the vertical scale was exactly 1 while the horizontal scale was\n * paneWidth/100, i.e. 4x to 12x. Under a non-uniform transform SVG scales the stroked OUTLINE, not\n * the stroke width, so line weight tracked slope, dash rhythm changed with both slope and pane\n * width, marker dots rendered as horizontal dashes, and `fontSize={AXIS_TYPE}` came out 3px tall and\n * stretched four- to twelve-fold sideways. Anamorphic letterforms, shipped to type designers.\n *\n * Worse, GUTTER was a pixel value pasted into a percentage space, so 52% of the chart was empty\n * margin and the data occupied 36% of the width.\n *\n * 760 matches TrendChart, which had this right all along. Uniform scaling, default\n * preserveAspectRatio, every constant in one space.\n */\nconst WIDTH = 760\n/** Height of one series row. */\n/** Space below each baseline, so a row's marks do not touch the row beneath. */\nconst BASELINE_GUTTER = 18\n/** Space under the last row for the shared date axis. */\nconst AXIS_HEIGHT = 26\n/** Left gutter for each row's own y-axis labels — 7% of the width, not 52%. */\nconst GUTTER = 52\nconst RIGHT_PAD = 12\nconst TOP_PAD = 22\n/** Type size inside the plot. A real value now that the scale is uniform. */\nconst AXIS_TYPE = 11\n/**\n * Height reserved above each row's plot for its label, in user units.\n *\n * The row's total height is this strip plus the data band plus the baseline gutter, and the band\n * now tracks the plot width — so reserving the strip costs the row's height rather than the data's.\n * An earlier version carved it out of a fixed row and left a 40px plot inside a 74px one.\n *\n * Enough for AXIS_TYPE plus its descender and a little air. The plot starts below it, so a row's\n * peak can reach the top of its scale without meeting its own name.\n */\nconst LABEL_STRIP = 16\n/**\n * Shortest span a brush may apply.\n *\n * Matches the chart's own render floor. Anything shorter narrows the range to a window the chart\n * cannot draw, which removes the control the reader would use to get back out.\n */\nconst MIN_BRUSH_DAYS = 3\n\n/** Format a value in its series' unit. */\nfunction formatValue(value: number, unit: SeriesUnit, currency: string | null | undefined): string {\n\tif (unit === 'money') return formatMoney(value, currency ?? null)\n\tif (unit === 'percent') return formatPercent(value, 1)\n\treturn formatCount(value)\n}\n\n/** Short date for an axis tick: `5 Sep`. */\nfunction tickLabel(date: Date): string {\n\treturn `${date.getUTCDate()} ${date.toLocaleString('en-GB', { month: 'short', timeZone: 'UTC' })}`\n}\n\n/**\n * Map a pointer's page X to a day index.\n *\n * Extracted from the component so it can be tested: `indexAt` needs a live\n * `getBoundingClientRect`, and these tests render to static markup with no DOM — which is exactly\n * why the bug this guards against shipped. The chart's viewBox tracks the measured width, and this\n * conversion was left dividing through the 760-unit SSR default, so on a 500px pane the right third\n * of pointer travel clamped to the last day and on a 900px pane the last sixth of days could not be\n * reached at all. The crosshair did not land under the cursor and a brush did not select the span\n * that was dragged.\n *\n * @param clientX - the pointer's viewport X\n * @param left - the chart box's viewport left edge\n * @param boxWidth - the chart box's rendered CSS width; 0 or less yields null\n * @param viewBoxWidth - the width the viewBox is currently set to, in user units\n * @param plotWidth - the drawable width inside the gutters, in user units\n * @param count - how many days are plotted\n */\nexport function dayIndexAt(\n\tclientX: number,\n\tleft: number,\n\tboxWidth: number,\n\tviewBoxWidth: number,\n\tplotWidth: number,\n\tcount: number,\n): number | null {\n\tif (count === 0 || boxWidth <= 0 || plotWidth <= 0) return null\n\tconst ratio = ((clientX - left) / boxWidth) * viewBoxWidth\n\tconst clamped = Math.max(GUTTER, Math.min(GUTTER + plotWidth, ratio))\n\tconst index = Math.round(((clamped - GUTTER) / plotWidth) * (count - 1))\n\treturn Math.max(0, Math.min(count - 1, index))\n}\n\n/** A sustained collapse in one source's coverage, as opposed to a standing shortfall. */\nexport interface CoverageIncident {\n\t/** First day of the run. */\n\tonset: string\n\t/** How many consecutive days it has lasted. */\n\tdays: number\n\t/** Typical coverage before it, 0 to 1. */\n\tbefore: number\n\t/** Typical coverage during it, 0 to 1. */\n\tduring: number\n\t/** Whether the run reaches the last measured day, i.e. it has not recovered. */\n\tongoing: boolean\n}\n\n/**\n * Tell a dated incident apart from a constant shortfall.\n *\n * The summary used to give a total and a single worst day, which cannot distinguish \"GA4 always\n * sees a fifth of the traffic\" from \"GA4 saw everything until the 24th, then almost nothing for ten\n * days\" — and the second is the founding case of this entire package. The two need opposite\n * responses: one is a measurement caveat to live with, the other is a fault with a date on it.\n *\n * The method is deliberately blunt, because the input is noisy at these volumes: take each day's\n * coverage, take the median as the range's normal, and look for a run of consecutive days sitting\n * far below that normal. A median is used rather than a mean so the incident itself does not define\n * the baseline it is measured against.\n *\n * @param days - coverage per day in date order, null on days that cannot be measured\n * @param dates - the matching dates, same length and order\n */\nexport function findCoverageIncident(\n\tdays: Array<number | null>,\n\tdates: string[],\n\t/**\n\t * The complete source's volume on each day, same length and order.\n\t *\n\t * Days too quiet to mean anything are excluded before anything is measured. Without this the\n\t * detector reads a ratio on an unbounded denominator: at a couple of dozen pageviews a day, a\n\t * quiet weekend with three pageviews and none of them seen is coverage 0.0, three of those in a\n\t * row clears the minimum run, and the loudest card in the tool prints a DATED accusation about a\n\t * day on which nothing happened. Someone then goes and pays to investigate it.\n\t *\n\t * The worst-day sentence twenty lines from here already learned this and wrote the reasoning\n\t * down; the superlative got a floor and the accusation did not, which is the wrong way round.\n\t */\n\tvolumes: Array<number | null> = [],\n): CoverageIncident | null {\n\t// A tenth of the busiest day, matching the floor the worst-day sentence uses.\n\tconst busiest = volumes.reduce<number>((best, v) => Math.max(best, v ?? 0), 0)\n\tconst floor = busiest > 0 ? Math.max(1, busiest * 0.1) : 0\n\tconst usable = days.map((value, i) => {\n\t\tconst volume = volumes[i]\n\t\tif (floor > 0 && (volume === null || volume === undefined || volume < floor)) return null\n\t\treturn value\n\t})\n\tdays = usable\n\n\tconst measured = days.filter((d): d is number => d !== null)\n\t// Three weeks of daily figures before this is worth attempting. Below that a \"run\" is as likely\n\t// to be a quiet fortnight as a fault, and naming a date carries more authority than the evidence.\n\tif (measured.length < 21) return null\n\n\tconst sorted = [...measured].sort((a, b) => a - b)\n\t/*\n\t * The 75th percentile, not the median.\n\t *\n\t * A median tolerates contamination only to half the sample, so once an outage covered more than\n\t * half the window the median WAS the outage: the threshold dropped to half the collapsed level,\n\t * the healthy days before it were never \"low\", and the function returned null. Detection got\n\t * quieter as the fault got worse, which is the opposite of what an alarm is for. A 46-day\n\t * collapse in a 90-day quarter — the default range — was silent.\n\t */\n\tconst normal = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]!\n\t// A source that never clears a tenth leaves no room for a fall to be detected against it. That\n\t// is a total shortfall, which the coverage row draws as a flat line near zero.\n\tif (normal <= 0.1) return null\n\n\tconst threshold = normal * 0.5\n\tconst MIN_RUN = 3\n\t// A run survives up to two consecutive unmeasured days. A null used to end it, and a null is\n\t// routine — a day with no traffic at all yields one — so a ten-day outage split into 6 and 3,\n\t// the longer half was reported, and the onset date named a day INSIDE the outage rather than\n\t// its start. That date is the one thing the reader acts on.\n\tconst MAX_GAP = 2\n\n\t// Every candidate, longest first. The \"needs a before\" rule used to be applied to the single\n\t// longest run and then ABORT the whole search, so a range that opens mid-outage reported\n\t// nothing at all — including any later incident it did have.\n\tconst runs: Array<{ start: number; end: number }> = []\n\tlet runStart: number | null = null\n\tlet gap = 0\n\tfor (let i = 0; i <= days.length; i++) {\n\t\tconst value = i < days.length ? days[i] : undefined\n\t\tconst low = value !== null && value !== undefined && value < threshold\n\t\tconst unmeasured = i < days.length && (value === null || value === undefined)\n\n\t\tif (low) {\n\t\t\tif (runStart === null) runStart = i\n\t\t\tgap = 0\n\t\t} else if (unmeasured && runStart !== null && gap < MAX_GAP) {\n\t\t\tgap += 1\n\t\t} else if (runStart !== null) {\n\t\t\t// The run ends at the last LOW day, not at the gap that followed it.\n\t\t\truns.push({ start: runStart, end: i - gap })\n\t\t\trunStart = null\n\t\t\tgap = 0\n\t\t}\n\t}\n\truns.sort((a, b) => (b.end - b.start) - (a.end - a.start))\n\n\tconst mean = (values: number[]) => values.reduce((sum, v) => sum + v, 0) / values.length\n\n\tfor (const run of runs) {\n\t\tif (run.end - run.start < MIN_RUN) continue\n\t\tconst during = days.slice(run.start, run.end).filter((d): d is number => d !== null)\n\t\tconst before = days.slice(0, run.start).filter((d): d is number => d !== null)\n\t\t// Without a before there is nothing to call this a CHANGE from — it is the range's normal.\n\t\tif (before.length < 3 || during.length === 0) continue\n\n\t\tconst onset = dates[run.start]\n\t\tif (!onset) continue\n\n\t\tconst beforeMean = mean(before)\n\t\tconst after = days.slice(run.end).filter((d): d is number => d !== null)\n\t\t/*\n\t\t * \"Recovered\" is a claim, and it now has to be earned.\n\t\t *\n\t\t * It used to mean only that the run did not touch the end of the array — so a null final day,\n\t\t * or one day back above the threshold, printed \"then recovered\" while coverage sat at half\n\t\t * its old level. This is the tool's one dated alarm and it could stand itself down on no\n\t\t * evidence. Recovery means measured days after the run, averaging back near where they were.\n\t\t */\n\t\tconst recovered = after.length >= MIN_RUN && mean(after) >= beforeMean * 0.8\n\n\t\treturn {\n\t\t\tonset,\n\t\t\tdays: run.end - run.start,\n\t\t\tbefore: beforeMean,\n\t\t\tduring: mean(during),\n\t\t\tongoing: !recovered,\n\t\t}\n\t}\n\n\treturn null\n}\n\nexport function CrossSourceTimeline({ series, markers = [], currency, onBrush }: CrossSourceTimelineProps): React.ReactElement | null {\n\tconst [hoverIndex, setHoverIndex] = useState<number | null>(null)\n\t// Whether to draw the constituent sources. Hover reveals them; so does keyboard focus and the\n\t// explicit toggle, because a chart whose detail exists only under a pointer is unreachable on a\n\t// phone and to anyone navigating by keyboard.\n\t/** Index the drag began at, or null when not dragging. */\n\tconst [brushAnchor, setBrushAnchor] = useState<number | null>(null)\n\t/**\n\t * The span being dragged, in day indices. Null when no drag is in flight.\n\t *\n\t * Deliberately NOT kept after release. Committing changes the range, which replaces the whole\n\t * series underneath these indices — so a selection left drawn would be positioned against days\n\t * that no longer exist, which is how it once produced NaN geometry. An earlier version of this\n\t * comment claimed the selection stays drawn to confirm the gesture; it never did.\n\t */\n\tconst [brushed, setBrushed] = useState<[number, number] | null>(null)\n\t/** A stable id for this mounted chart, so its SVG defs cannot collide with another instance's. */\n\tconst instanceId = React.useId().replace(/[^a-zA-Z0-9]/g, '')\n\t// Whether the reader arrived at this day by keyboard. Only then does the readout announce.\n\t//\n\t// The region is driven by `hoverIndex`, which a slow pointer sweep across ninety days changes\n\t// about ninety times — so a screen-reader user got the whole chart twice, once as the static\n\t// summary and once as an unthrottled stream. Keyboard stepping is the case the live region was\n\t// added for, and it changes one day at a time on purpose.\n\tconst [steppedByKeyboard, setSteppedByKeyboard] = useState(false)\n\n\t/** Whether the last drag was too short to apply, so the chart can say so instead of ignoring it. */\n\tconst [tooShort, setTooShort] = useState(false)\n\t/**\n\t * The chart's rendered width in CSS pixels, so the viewBox can track it.\n\t *\n\t * With a FIXED 760-unit viewBox and height:auto, the uniform scale is paneWidth/760 — so\n\t * `fontSize={11}` rendered at 5.8px in a 400px Studio pane and ~22px at full width, where the\n\t * row labels came out larger than the section heading above them. The chart's height swung the\n\t * same way, which made row height a function of how wide the pane happened to be.\n\t *\n\t * Making the viewBox equal the measured width pins the scale at exactly 1: type is 11px\n\t * everywhere, stroke weights are what they say, and only the x range responds to the pane.\n\t * WIDTH is the server-render default, and the first client measurement replaces it.\n\t */\n\tconst [measured, setMeasured] = useState(WIDTH)\n\tconst frameRef = useRef<SVGSVGElement | null>(null)\n\t/**\n\t * The chart element, as STATE as well as a ref.\n\t *\n\t * A ref alone cannot drive the measuring effect. The component returns null below three dates,\n\t * so on a mount with no data the ref is null when the effect runs — and with a stable dep array\n\t * the effect never runs again, leaving the chart pinned at the 760 default with no path out. A\n\t * callback ref makes attachment an observable event.\n\t */\n\tconst [frameNode, setFrameNode] = useState<SVGSVGElement | null>(null)\n\tconst attachFrame = useCallback((node: SVGSVGElement | null) => {\n\t\tframeRef.current = node\n\t\tsetFrameNode(node)\n\t}, [])\n\n\t// Every date any series reported, ascending. Built from the union so a series with a gap does\n\t// not shorten the axis for the others.\n\tconst dates = useMemo(() => {\n\t\tconst all = new Set<string>()\n\t\tfor (const s of series) for (const p of s.points) all.add(p.date)\n\t\treturn [...all].sort()\n\t}, [series])\n\n\tconst plotWidth = Math.max(120, measured - GUTTER - RIGHT_PAD)\n\n\t/*\n\t * Row height tracks the plot width, instead of being a constant.\n\t *\n\t * The chart measures itself to hold type at 11px — a typographic concern — and then left the\n\t * geometric one alone: a fixed 58px data band is 336x58 at a narrow pane and 1336x58 at a wide\n\t * one. Twenty-three to one. Every series flattens into a horizontal smear exactly where the\n\t * reader has the most room to look, and slope is the whole reason a time series is a line.\n\t *\n\t * Banked loosely toward a readable aspect and clamped at both ends, so a narrow pane does not\n\t * get a squashed row and a wide one does not get a chart that will not fit on screen.\n\t */\n\tconst bandHeight = Math.round(Math.max(58, Math.min(150, plotWidth / 7)))\n\tconst rowHeight = bandHeight + LABEL_STRIP + BASELINE_GUTTER\n\n\t// useLayoutEffect, and it measures once before the browser paints. With a plain effect the\n\t// first frame drew a 760-unit viewBox inside a real-width box with a fixed CSS height, and the\n\t// default preserveAspectRatio letterboxed it — a 400px pane rendered the whole chart at 52%\n\t// scale, centred in dead space, then snapped. That happened on every mount, so every tab switch\n\t// and every range change flashed it.\n\tReact.useLayoutEffect(() => {\n\t\tconst frame = frameNode\n\t\tif (!frame) return\n\n\t\t// Functional, so the effect does not depend on `measured`. Depending on it tore the\n\t\t// observer down and rebuilt it on every width change — avoidable churn, and the shape that\n\t\t// produces \"ResizeObserver loop completed with undelivered notifications\".\n\t\tconst apply = (width: number) => {\n\t\t\t// Rounded, so a sub-pixel resize does not churn the whole path set. A zero width is a\n\t\t\t// collapsed or hidden pane and carries no information, so the last good width stands.\n\t\t\tif (width <= 0) return\n\t\t\tconst next = Math.round(width)\n\t\t\tsetMeasured((current) => (Math.abs(next - current) > 1 ? next : current))\n\t\t}\n\n\t\tapply(frame.getBoundingClientRect().width)\n\t\tif (typeof ResizeObserver === 'undefined') return\n\n\t\tconst observer = new ResizeObserver((entries) => {\n\t\t\tconst width = entries[0]?.contentRect.width\n\t\t\tif (width !== undefined) apply(width)\n\t\t})\n\t\tobserver.observe(frame)\n\t\treturn () => observer.disconnect()\n\t}, [frameNode])\n\tconst height = TOP_PAD + series.length * rowHeight + AXIS_HEIGHT\n\n\tconst x = useMemo(() => {\n\t\tif (dates.length === 0) return null\n\t\treturn scaleUtc()\n\t\t\t.domain([new Date(`${dates[0]}T00:00:00Z`), new Date(`${dates[dates.length - 1]}T00:00:00Z`)])\n\t\t\t.range([GUTTER, GUTTER + plotWidth])\n\t}, [dates, plotWidth])\n\n\t/** Pointer position to a day index, in the shared viewBox space. */\n\tconst indexAt = useCallback(\n\t\t(clientX: number) => {\n\t\t\tconst frame = frameRef.current\n\t\t\tif (!frame || dates.length === 0) return null\n\t\t\tconst box = frame.getBoundingClientRect()\n\t\t\treturn dayIndexAt(clientX, box.left, box.width, measured, plotWidth, dates.length)\n\t\t},\n\t\t[dates.length, plotWidth, measured],\n\t)\n\n\t/**\n\t * Apply a span, if it is long enough to draw.\n\t *\n\t * The floor is three days because that is what the chart itself needs — it returns null below\n\t * three dates, and the panel gates the whole section the same way. A two-day brush was legal\n\t * and produced a window in which the timeline, its heading, its data table and the brush\n\t * affordance all disappeared: the instrument removed itself, leaving no way to widen back.\n\t */\n\tconst commit = useCallback(\n\t\t(from: number, to: number) => {\n\t\t\tif (!onBrush) return\n\t\t\t// Says so. This returned early and left nothing on screen, so the most likely mis-drag —\n\t\t\t// a short one — read as the chart ignoring the reader. The comment on the pointer-leave\n\t\t\t// handler rejects exactly this (\"a silent no-op that looks like success\") and then the\n\t\t\t// commit path did it.\n\t\t\tif (to - from < MIN_BRUSH_DAYS - 1) {\n\t\t\t\t// A click is not a failed drag. `to === from` means the pointer never moved — which is\n\t\t\t\t// the ordinary gesture for reading a day — and scolding it fired the refusal far more\n\t\t\t\t// often on non-gestures than on real mis-drags, training the reader to ignore the one\n\t\t\t\t// line of copy that carries a genuine one.\n\t\t\t\tsetTooShort(to > from)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetTooShort(false)\n\t\t\tconst start = dates[from]\n\t\t\tconst end = dates[to]\n\t\t\tif (start && end) onBrush(start, end)\n\t\t},\n\t\t[onBrush, dates],\n\t)\n\n\tconst onMove = useCallback(\n\t\t(event: React.PointerEvent<SVGSVGElement>) => {\n\t\t\tconst index = indexAt(event.clientX)\n\t\t\tif (index === null) return\n\t\t\tsetSteppedByKeyboard(false)\n\t\t\tsetHoverIndex(index)\n\t\t\t// Mid-drag: extend the selection rather than only moving the crosshair.\n\t\t\tif (brushAnchor !== null) {\n\t\t\t\tconst from = Math.min(brushAnchor, index)\n\t\t\t\tconst to = Math.max(brushAnchor, index)\n\t\t\t\t// Only when the span actually changes. A fresh array every pointer move re-rendered\n\t\t\t\t// the chart — and re-announced its live readout — on every pixel of a drag, where\n\t\t\t\t// setHoverIndex alone bails out on an unchanged index.\n\t\t\t\tsetBrushed((current) => (current && current[0] === from && current[1] === to ? current : [from, to]))\n\t\t\t}\n\t\t},\n\t\t[indexAt, brushAnchor],\n\t)\n\n\t/**\n\t * Which days get a date label, spread evenly from first to last.\n\t *\n\t * Always includes both ends. The count is driven by the measured plot width rather than fixed,\n\t * so a wide pane is not left with three labels across a metre of axis.\n\t *\n\t * ABOVE the early return, and it must stay there. It sat below, so any render that bailed —\n\t * fewer than three days, no series — called one hook fewer than the render before it, and React\n\t * throws \"Rendered fewer hooks than expected\", taking the whole Studio pane rather than the\n\t * chart. It was masked only because the panel happens to gate on the same three-day minimum,\n\t * which makes this component's own documented guard dead code and the safety one caller away.\n\t */\n\tconst tickIndexes = useMemo(() => {\n\t\tif (dates.length === 0) return []\n\t\tif (dates.length === 1) return [0]\n\t\tconst affordable = Math.max(2, Math.min(8, Math.floor(plotWidth / 90)))\n\t\tconst step = (dates.length - 1) / (affordable - 1)\n\t\tconst indexes = Array.from({ length: affordable }, (_, i) => Math.round(i * step))\n\t\t// Deduped: a short range can round two slots onto the same day, which would draw one label\n\t\t// on top of another and give the axis two identical ends.\n\t\treturn [...new Set(indexes)]\n\t}, [dates.length, plotWidth])\n\n\tif (dates.length < 3 || series.length === 0 || !x) return null\n\n\tconst hoveredDate = hoverIndex !== null ? dates[hoverIndex] : null\n\n\n\t// What the drawing says, in words. Serves a screen reader, anyone who cannot resolve the axis\n\t// type, and anyone reading a screenshot — the shaded region's whole argument was previously\n\t// available only as pixels.\n\tconst shapeOf = (row: Series) => {\n\t\tconst real = row.points.filter((p) => p.value !== null) as Array<{ date: string; value: number }>\n\t\tif (real.length === 0) return `${row.label}: no data`\n\t\tconst top = real.reduce((best, p) => (p.value > best.value ? p : best), real[0]!)\n\t\tconst total = real.reduce((sum, p) => sum + p.value, 0)\n\n\t\t// `role=\"img\"` prunes the descendants, so this sentence IS the chart for a screen reader —\n\t\t// it has to describe what is actually drawn. An event row draws stems on the days something\n\t\t// happened, so describing it as a shape \"peaking\" somewhere asserted a curve that does not\n\t\t// exist, and on an all-zero row it read \"peaking at $0\" beside a row drawing nothing.\n\t\tif (row.mark === 'events') {\n\t\t\tconst active = real.filter((p) => p.value !== 0)\n\t\t\tif (active.length === 0) {\n\t\t\t\treturn `${row.label} (${row.source}): nothing on any of the ${real.length} days measured`\n\t\t\t}\n\t\t\treturn `${row.label} (${row.source}): ${formatValue(total, row.unit, currency)} across `\n\t\t\t\t+ `${active.length} of ${real.length} days, the largest ${formatValue(top.value, row.unit, currency)} `\n\t\t\t\t+ `on ${tickLabel(new Date(`${top.date}T00:00:00Z`))}`\n\t\t}\n\n\t\treturn `${row.label} (${row.source}): ${formatValue(total, row.unit, currency)} in total, `\n\t\t\t+ `peaking at ${formatValue(top.value, row.unit, currency)} on ${tickLabel(new Date(`${top.date}T00:00:00Z`))}`\n\t}\n\n\tconst missed = series.flatMap((row) => {\n\t\tif (!row.shortfall) return []\n\t\tconst seen = new Map(row.shortfall.points.map((p) => [p.date, p.value]))\n\t\tlet gap = 0\n\t\tlet excess = 0\n\t\tlet worst: { date: string; ratio: number } | null = null\n\n\t\t// \"Widest\" needs a volume floor, because a ratio on an unbounded denominator is won by the\n\t\t// quietest day: one Vercel pageview and no GA4 scores a perfect 1.0 and beats a real\n\t\t// incident. At these volumes low-traffic days are routine, so without this the headline\n\t\t// sentence names a trivial Sunday most of the time. A tenth of the busiest day is the bar.\n\t\tconst busiest = row.points.reduce((best, p) => Math.max(best, p.value ?? 0), 0)\n\t\tconst floor = Math.max(1, busiest * 0.1)\n\n\t\tfor (const point of row.points) {\n\t\t\tif (point.value === null) continue\n\t\t\tconst saw = seen.get(point.date)\n\t\t\tif (saw == null) continue\n\t\t\tconst difference = point.value - saw\n\t\t\t// Signed sums kept apart. Adding them let days where GA4 counted MORE — a prefetch, a\n\t\t\t// double-firing tag — quietly cancel days where it counted less, so a site with both\n\t\t\t// problems reported no gap at all.\n\t\t\tif (difference >= 0) gap += difference\n\t\t\telse excess += -difference\n\t\t\tconst ratio = point.value > 0 ? 1 - saw / point.value : 0\n\t\t\tif (point.value >= floor && (!worst || ratio > worst.ratio)) worst = { date: point.date, ratio }\n\t\t}\n\n\t\t// Over-counting is its own finding, not the absence of one. It used to fall through the\n\t\t// `gap <= 0` guard and print nothing, so the failure mode with the loudest cause — a tag\n\t\t// installed twice — was the one the chart stayed silent about.\n\t\t// Both faults, separately, and never their difference. Printing `excess - gap` re-created\n\t\t// exactly the cancellation the accumulation above was split up to avoid: 1,100 over-counted\n\t\t// against 1,000 under-counted reported as \"100 more\". Returning early on it also suppressed\n\t\t// the under-count sentence — this tool's central figure — whenever a second, unrelated\n\t\t// defect happened to be larger.\n\t\tconst sentences: string[] = []\n\t\tif (excess > 0) {\n\t\t\tsentences.push(`${row.shortfall.source} counted ${formatValue(Math.round(excess), row.unit, currency)} more `\n\t\t\t\t+ `${row.label.toLowerCase()} than ${row.source} on some days, which usually means a tag firing twice `\n\t\t\t\t+ `rather than extra traffic.`)\n\t\t}\n\t\tif (gap <= 0) return sentences\n\n\t\t// An incident, if there is one. Stated BEFORE the total, because \"coverage fell on the 24th\n\t\t// and has not recovered\" is a different kind of fact from \"the gap was this big\" — the first\n\t\t// is a fault with a date, the second is a quantity to caveat.\n\t\tconst coverageByDay = row.points.map((point) => {\n\t\t\tif (point.value === null || point.value <= 0) return null\n\t\t\tconst saw = seen.get(point.date)\n\t\t\treturn saw == null ? null : saw / point.value\n\t\t})\n\t\tconst incident = findCoverageIncident(\n\t\t\tcoverageByDay,\n\t\t\trow.points.map((p) => p.date),\n\t\t\trow.points.map((p) => p.value),\n\t\t)\n\t\tif (incident) {\n\t\t\tsentences.push(\n\t\t\t\t`${row.shortfall.source} coverage fell from about ${formatPercent(incident.before, 0)} to `\n\t\t\t\t+ `${formatPercent(incident.during, 0)} on ${tickLabel(new Date(`${incident.onset}T00:00:00Z`))}`\n\t\t\t\t+ (incident.ongoing\n\t\t\t\t\t? `, and has stayed there for ${incident.days} days. That is a change on a date, not a standing shortfall — something altered on or around then.`\n\t\t\t\t\t: ` for ${incident.days} days, then recovered.`),\n\t\t\t)\n\t\t}\n\n\t\t// The total is stated whether or not a worst day can be named. It used to be discarded when\n\t\t// `worst` was null — which the volume floor made reachable, since a single big spike raises\n\t\t// the bar for every other day — so the tool's headline figure disappeared because the\n\t\t// SUPERLATIVE could not be computed. The floor was there to fix which day gets named, not\n\t\t// whether the total gets said.\n\t\t//\n\t\t// The unit, too: this built every sentence with formatCount, so a revenue shortfall read\n\t\t// \"Sanity missed 1,234 revenue\".\n\t\tconst total = `${row.shortfall.source} missed ${formatValue(Math.round(gap), row.unit, currency)} `\n\t\t\t+ `${row.label.toLowerCase()} over this period`\n\t\t// Only a positive ratio is a gap. `worst` takes the maximum among floor-clearing days, and\n\t\t// when every one of those was over-counted that maximum is negative — \"the gap was widest at\n\t\t// −24%\", which is not a gap.\n\t\tsentences.push(worst && worst.ratio > 0\n\t\t\t? `${total}; the gap was widest on ${tickLabel(new Date(`${worst.date}T00:00:00Z`))} at ${formatPercent(worst.ratio, 0)}.`\n\t\t\t: `${total}.`)\n\t\treturn sentences\n\t})\n\n\tconst summary = `${dates.length} days from ${tickLabel(new Date(`${dates[0]}T00:00:00Z`))} to `\n\t\t+ `${tickLabel(new Date(`${dates[dates.length - 1]}T00:00:00Z`))}. `\n\t\t+ series.map(shapeOf).join('. ') + '. ' + missed.join(' ')\n\t\t+ (markers.length > 0 ? ` ${markers.length} campaign send${markers.length === 1 ? '' : 's'} marked.` : '')\n\n\treturn (\n\t\t<Stack space={3}>\n\t\t\t{/*\n\t\t\t * ABOVE the chart, at reading size, in a card.\n\t\t\t *\n\t\t\t * These are the tool's most actionable output — how much a source missed, and the date\n\t\t\t * its coverage fell — and they were rendered `size={0} muted` inside the legend row,\n\t\t\t * between the series key and the drag instructions. The smallest, faintest type on the\n\t\t\t * page, styled as chart furniture, below the thing it explains. Three separate readers\n\t\t\t * independently reported it as the best sentence in the product and the hardest to find.\n\t\t\t */}\n\t\t\t{missed.length > 0 && (\n\t\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t{missed.map((sentence) => <Text key={sentence} size={1}>{sentence}</Text>)}\n\t\t\t\t\t</Stack>\n\t\t\t\t</Card>\n\t\t\t)}\n\n\t\t\t<div style={frameStyle}>\n\t\t\t\t<svg\n\t\t\t\t\tref={attachFrame}\n\t\t\t\t\tviewBox={`0 0 ${measured} ${height}`}\n\t\t\t\t\t// The viewBox tracks the measured width, so the scale is exactly 1 and every size\n\t\t\t\t\t// here is a real CSS pixel. Height is fixed rather than derived from the aspect\n\t\t\t\t\t// ratio, so rows keep their height whatever the pane does.\n\t\t\t\t\t// onPointerMove, not onMouseMove. The anchor and the commit were already pointer\n\t\t\t\t\t// events but the EXTENSION was not, and touch and pen send no compatibility\n\t\t\t\t\t// mousemove stream during a drag — so `brushed` stayed at [i, i], commit saw a\n\t\t\t\t\t// zero-length span, and the gesture did nothing while the legend advertised it.\n\t\t\t\t\tonPointerMove={onMove}\n\t\t\t\t\t// A selection in flight is abandoned when the pointer leaves, but nothing is left\n\t\t\t\t\t// drawn: a selection still on screen that never applied is a silent no-op that\n\t\t\t\t\t// looks like success.\n\t\t\t\t\t// tooShort is NOT cleared here. The message renders below the chart, so a reader who\n\t\t\t\t\t// mis-drags and then moves down to read the explanation crosses the boundary and\n\t\t\t\t\t// used to destroy it on the way. It clears on the next drag instead.\n\t\t\t\t\tonPointerLeave={() => { setHoverIndex(null); setBrushAnchor(null); setBrushed(null) }}\n\t\t\t\t\tonPointerDown={(event) => {\n\t\t\t\t\t\tif (!onBrush) return\n\t\t\t\t\t\tsetTooShort(false)\n\t\t\t\t\t\tconst index = indexAt(event.clientX)\n\t\t\t\t\t\tif (index === null) return\n\t\t\t\t\t\tevent.preventDefault()\n\t\t\t\t\t\t// Captured, so a drag that ends outside the chart still commits. \"From the\n\t\t\t\t\t\t// campaign send to today\" is the most natural gesture here and it finishes at\n\t\t\t\t\t\t// the right edge — without capture the pointer left, the anchor was dropped,\n\t\t\t\t\t\t// and nothing happened.\n\t\t\t\t\t\tevent.currentTarget.setPointerCapture(event.pointerId)\n\t\t\t\t\t\tsetBrushAnchor(index)\n\t\t\t\t\t\tsetBrushed([index, index])\n\t\t\t\t\t}}\n\t\t\t\t\tonPointerUp={(event) => {\n\t\t\t\t\t\tif (!onBrush || brushAnchor === null) return\n\t\t\t\t\t\tif (event.currentTarget.hasPointerCapture(event.pointerId)) {\n\t\t\t\t\t\t\tevent.currentTarget.releasePointerCapture(event.pointerId)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tconst index = indexAt(event.clientX) ?? hoverIndex ?? brushAnchor\n\t\t\t\t\t\tconst from = Math.min(brushAnchor, index)\n\t\t\t\t\t\tconst to = Math.max(brushAnchor, index)\n\t\t\t\t\t\tsetBrushAnchor(null)\n\t\t\t\t\t\tsetBrushed(null)\n\t\t\t\t\t\tcommit(from, to)\n\t\t\t\t\t}}\n\t\t\t\t\tstyle={{ width: '100%', height, display: 'block', cursor: onBrush ? 'col-resize' : 'default' }}\n\t\t\t\t\ttabIndex={0}\n\t\t\t\t\trole=\"img\"\n\t\t\t\t\t// The label carries the SHAPE, not just the subject. It previously said only which\n\t\t\t\t\t// rows existed, which is the chart's title rather than its content — and since\n\t\t\t\t\t// role=\"img\" prunes every descendant, that was the entire chart for a blind\n\t\t\t\t\t// reader. Peaks, dates and the size of the gap are the facts the drawing conveys.\n\t\t\t\t\taria-label={summary}\n\t\t\t\t\tonFocus={() => setHoverIndex((current) => current ?? dates.length - 1)}\n\t\t\t\t\tonBlur={() => setHoverIndex(null)}\n\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\t// The chart this replaced had arrow-key day stepping and this one did not, so\n\t\t\t\t\t\t// the newer, more prominent chart regressed against the older one.\n\t\t\t\t\t\tlet next: number | null = null\n\t\t\t\t\t\tconst current = hoverIndex ?? dates.length - 1\n\t\t\t\t\t\tif (event.key === 'ArrowRight') next = Math.min(current + 1, dates.length - 1)\n\t\t\t\t\t\tif (event.key === 'ArrowLeft') next = Math.max(current - 1, 0)\n\t\t\t\t\t\tif (event.key === 'Home') next = 0\n\t\t\t\t\t\tif (event.key === 'End') next = dates.length - 1\n\t\t\t\t\t\tif (event.key === 'Escape') { setHoverIndex(null); setBrushed(null); return }\n\t\t\t\t\t\tif (event.key === 'Enter' && brushed && onBrush) {\n\t\t\t\t\t\t\tevent.preventDefault()\n\t\t\t\t\t\t\tcommit(brushed[0], brushed[1])\n\t\t\t\t\t\t\tsetBrushed(null)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (next === null) return\n\t\t\t\t\t\tevent.preventDefault()\n\t\t\t\t\t\tsetSteppedByKeyboard(true)\n\t\t\t\t\t\tsetHoverIndex(next)\n\t\t\t\t\t\t// Shift extends a selection, so the brush is reachable without a pointer —\n\t\t\t\t\t\t// which matters more here than usual, since this is the tool's one\n\t\t\t\t\t\t// cross-filter and the pane is often narrow.\n\t\t\t\t\t\tif (event.shiftKey && onBrush) {\n\t\t\t\t\t\t\t// A FIXED anchor, held in its own state. Re-reading it as `brushed[0]` — the\n\t\t\t\t\t\t\t// minimum — meant extending leftward moved both ends together: [88,89] became\n\t\t\t\t\t\t\t// [87,88] became [86,87], stuck at two days forever. Since focus seeds the\n\t\t\t\t\t\t\t// cursor at the last day, shift+ArrowLeft is the natural keyboard gesture, so\n\t\t\t\t\t\t\t// the only cross-filter in the tool always failed and always scolded.\n\t\t\t\t\t\t\tconst anchor = brushAnchor ?? current\n\t\t\t\t\t\t\tif (brushAnchor === null) setBrushAnchor(current)\n\t\t\t\t\t\t\tsetBrushed([Math.min(anchor, next), Math.max(anchor, next)])\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{/* A rule at every date label, behind the data.\n\t\t\t\t\t Without them the only way to answer \"which day is this peak\" was to hold a\n\t\t\t\t\t pointer on it: three labels sat 38px below the last baseline with nothing\n\t\t\t\t\t joining them to the rows, so dating a feature meant tracing an unruled column by\n\t\t\t\t\t eye. Faint enough to stay behind the marks, present enough to read against. */}\n\t\t\t\t\t{tickIndexes.map((index) => {\n\t\t\t\t\t\tconst date = dates[index]\n\t\t\t\t\t\tif (!date) return null\n\t\t\t\t\t\tconst at = x(new Date(`${date}T00:00:00Z`))\n\t\t\t\t\t\tif (!Number.isFinite(at)) return null\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<line\n\t\t\t\t\t\t\t\tkey={`grid-${date}`}\n\t\t\t\t\t\t\t\tx1={at}\n\t\t\t\t\t\t\t\tx2={at}\n\t\t\t\t\t\t\t\ty1={TOP_PAD}\n\t\t\t\t\t\t\t\t// Down to the axis type, not to the last baseline: the rules existed to let a\n\t\t\t\t\t\t\t\t// reader date a peak without hovering, and stopping 38px short of the labels\n\t\t\t\t\t\t\t\t// they connect to left exactly the gap they were meant to close.\n\t\t\t\t\t\t\t\ty2={height - AXIS_HEIGHT + 4}\n\t\t\t\t\t\t\t\tstroke={mark('chart.grid')}\n\t\t\t\t\t\t\t\tstrokeWidth={1}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\n\t\t\t\t\t{series.map((row, rowIndex) => {\n\t\t\t\t\t\t// One colour per row, resolved once. Every mark in this row — line, stems, zero\n\t\t\t\t\t\t// ticks, region, label swatch — draws in it, so the row reads as one thing.\n\t\t\t\t\t\tconst stroke = SERIES[colorFor(row.source, row.unit, row.color)]\n\t\t\t\t\t\tconst shortfallStroke = row.shortfall\n\t\t\t\t\t\t\t? SERIES[colorFor(row.shortfall.source, row.unit, row.shortfall.color)]\n\t\t\t\t\t\t\t: null\n\t\t\t\t\t\tconst top = TOP_PAD + rowIndex * rowHeight\n\t\t\t\t\t\tconst bottom = top + rowHeight - BASELINE_GUTTER\n\t\t\t\t\t\t// The row label gets its own strip above the plot rather than sharing it. The\n\t\t\t\t\t\t// label sat at `top + 12` with the scale's maximum AT `top`, so by construction\n\t\t\t\t\t\t// the tallest point of every row — the peak day, the thing the row exists to\n\t\t\t\t\t\t// show — passed under its own name, along with the axis-max figure at `top + 4`.\n\t\t\t\t\t\t// Roughly a third of each band was type over data.\n\t\t\t\t\t\tconst plotTop = top + LABEL_STRIP\n\n\t\t\t\t\t\tconst values = row.points.map((p) => p.value).filter((v): v is number => v !== null)\n\t\t\t\t\t\t// The comparison counts toward the scale. It is drawn through the same generator\n\t\t\t\t\t\t// and inside the same clip, so a previous period that peaked HIGHER than this one\n\t\t\t\t\t\t// was cut off at the plot top rather than squashed — and a reader saw a ghost that\n\t\t\t\t\t\t// ran along the ceiling and flattened, which reads as \"last period was flat\" when\n\t\t\t\t\t\t// it was in fact bigger. The ghost is the only reference level on the chart; it has\n\t\t\t\t\t\t// to be inside the domain to be one.\n\t\t\t\t\t\tconst comparisonValues = (row.comparison ?? [])\n\t\t\t\t\t\t\t.map((p) => p.value)\n\t\t\t\t\t\t\t.filter((v): v is number => v !== null)\n\t\t\t\t\t\t// Each row scales to ITSELF. That is the whole point of small multiples: no\n\t\t\t\t\t\t// shared scale means no invented correlation between rows.\n\t\t\t\t\t\tconst peak = d3Max([...values, ...comparisonValues]) ?? 0\n\t\t\t\t\t\t// The floor drops below zero when the data does. Clipping each row to its band was\n\t\t\t\t\t\t// right, but the domain still started at 0, so a refund was positioned below the\n\t\t\t\t\t\t// baseline and therefore outside the clip — it went from bleeding into the next\n\t\t\t\t\t\t// row to not being drawn at all, while `shapeOf` still counted it in the row's\n\t\t\t\t\t\t// spoken total. The clip moved that bug rather than fixing it.\n\t\t\t\t\t\tconst lowest = Math.min(0, ...values, ...comparisonValues)\n\t\t\t\t\t\tconst y = row.domain\n\t\t\t\t\t\t\t? scaleLinear().domain(row.domain).range([bottom, plotTop])\n\t\t\t\t\t\t\t: scaleLinear().domain([lowest, peak || 1]).nice().range([bottom, plotTop])\n\t\t\t\t\t\t// Where zero sits once the domain may be negative — the baseline rule and the\n\t\t\t\t\t\t// stems both hang off this rather than off the bottom of the band.\n\t\t\t\t\t\tconst zeroY = y(0)\n\n\t\t\t\t\t\tconst at = (p: SeriesPoint) => x(new Date(`${p.date}T00:00:00Z`))\n\t\t\t\t\t\tconst defined = (p: SeriesPoint) => p.value !== null\n\n\t\t\t\t\t\t// Linear, not monotone. The file's own header argues that a smooth curve through\n\t\t\t\t\t\t// seven sales draws revenue on days that had none, and switches those rows to\n\t\t\t\t\t\t// stems — then kept the curve on the count rows, where it invents values between\n\t\t\t\t\t\t// measured days just the same. On a week across a wide pane that is a chart made\n\t\t\t\t\t\t// almost entirely of interpolation.\n\t\t\t\t\t\tconst lineGen = d3Line<SeriesPoint>().defined(defined).x(at).y((p) => y(p.value as number)).curve(curveLinear)\n\n\t\t\t\t\t\t// The blind spot: the area between what happened and what the lossier source\n\t\t\t\t\t\t// saw of it. Filled, not outlined, because it is a QUANTITY — the traffic the\n\t\t\t\t\t\t// analytics missed, drawn to scale, rather than a percentage on another tab.\n\t\t\t\t\t\t// Always visible, never hover-gated: the 24 August collapse announced itself\n\t\t\t\t\t\t// as this region widening, and putting that behind an interaction would\n\t\t\t\t\t\t// switch the alarm off.\n\t\t\t\t\t\tconst shortfallPoints = row.shortfall?.points ?? []\n\t\t\t\t\t\tconst path = row.mark === 'events' ? '' : lineGen(row.points) ?? ''\n\n\t\t\t\t\t\t// The previous window, positioned by offset: its own dates are meaningless here,\n\t\t\t\t\t\t// so point i is drawn at the x of day i of THIS window. That is what makes the\n\t\t\t\t\t\t// two shapes comparable rather than merely adjacent.\n\t\t\t\t\t\tconst ghost = row.comparison && row.comparison.length > 1\n\t\t\t\t\t\t\t? lineGen(row.comparison.slice(0, dates.length).map((point, i) => ({\n\t\t\t\t\t\t\t\tdate: dates[i] ?? point.date,\n\t\t\t\t\t\t\t\tvalue: point.value,\n\t\t\t\t\t\t\t}))) ?? ''\n\t\t\t\t\t\t\t: ''\n\n\t\t\t\t\t\t// One stem per day that had one. Width tracks the day slot so a year of data\n\t\t\t\t\t\t// stays a texture rather than a picket fence, with a floor so a single order\n\t\t\t\t\t\t// in a quarter is still findable.\n\t\t\t\t\t\t// The pitch is between ENDPOINTS, so it divides by the number of gaps, not the\n\t\t\t\t\t\t// number of days. The inter-stem gutter only exists when there is room for\n\t\t\t\t\t\t// one: at 365 days in a narrow pane the pitch is under a pixel, `pitch - 1`\n\t\t\t\t\t\t// goes negative, and the 1.5 floor then drew every stem wider than its own\n\t\t\t\t\t\t// day — adjacent days merged into a solid slab that overstated each one.\n\t\t\t\t\t\tconst pitch = plotWidth / Math.max(1, dates.length - 1)\n\t\t\t\t\t\tconst stemWidth = Math.max(1, Math.min(9, pitch > 2 ? pitch - 1 : pitch))\n\t\t\t\t\t\t// Non-zero, either sign. Filtering on `> 0` dropped refunds and chargebacks from\n\t\t\t\t\t\t// the drawing while `shapeOf` still counted them in the total, so the picture\n\t\t\t\t\t\t// and its own description disagreed.\n\t\t\t\t\t\tconst stems = row.mark === 'events'\n\t\t\t\t\t\t\t? row.points.filter((p) => p.value !== null && (p.value as number) !== 0)\n\t\t\t\t\t\t\t: []\n\n\t\t\t\t\t\t// Days measured as zero, marked on the baseline. Drawing nothing for them made a\n\t\t\t\t\t\t// day with no sales identical to a day Sanity never reported — the one\n\t\t\t\t\t\t// distinction this package exists to keep (see the header of Figure.tsx). The\n\t\t\t\t\t\t// tick is at the axis, so it reads as \"measured, and it was nothing\".\n\t\t\t\t\t\t// Only where they can read as SEPARATE marks. At ninety days in a narrow pane the\n\t\t\t\t\t\t// pitch is under four pixels, so eighty-odd abutting ticks merge into a slightly\n\t\t\t\t\t\t// darker baseline — a thicker axis, not a row of measurements — and now that the\n\t\t\t\t\t\t// series is a complete calendar that is the normal case rather than the rare one.\n\t\t\t\t\t\t// Below the threshold the row being drawn at all is what says it was measured.\n\t\t\t\t\t\tconst zeroes = row.mark === 'events' && pitch >= 6\n\t\t\t\t\t\t\t? row.points.filter((p) => p.value === 0)\n\t\t\t\t\t\t\t: []\n\t\t\t\t\t\t// The lossier source's own line, drawn always — it is named in the legend below.\n\t\t\t\t\t\t// The SAME generator, nulls left in, so `.defined()` breaks the line where GA4\n\t\t\t\t\t\t// reported nothing. Filtering them out first joined the surviving points into a\n\t\t\t\t\t\t// continuous stroke — so a GA4 outage, the single event this chart was built to\n\t\t\t\t\t\t// expose, was drawn as a smooth line straight through the missing days. The\n\t\t\t\t\t\t// shaded region beneath already broke correctly, so the fill and the line\n\t\t\t\t\t\t// disagreed about the same days and the one on top was the one that lied.\n\t\t\t\t\t\t// ALWAYS, not only when revealed.\n\t\t\t\t\t\t//\n\t\t\t\t\t\t// This was gated on the hover/pin state, so the region's own boundary existed\n\t\t\t\t\t\t// only while someone was pointing at the chart — and at rest the region was a\n\t\t\t\t\t\t// 1.40:1 wash with no perceivable extent at all. MARKS permits a fill under the\n\t\t\t\t\t\t// contrast floor only because it names a boundary that clears it; a boundary\n\t\t\t\t\t\t// behind an interaction does not satisfy that, it just hides the shortfall.\n\t\t\t\t\t\tconst shortfallLine = row.shortfall ? lineGen(shortfallPoints) ?? '' : ''\n\n\t\t\t\t\t\t// Every mark is clipped to its own band.\n\t\t\t\t\t\t//\n\t\t\t\t\t\t// scaleLinear does not clamp, so two states the chart was recently taught to\n\t\t\t\t\t\t// draw escape their row: GA4 counting MORE than the complete source puts the\n\t\t\t\t\t\t// fill above the plot top and through the label strip into the row above, and a\n\t\t\t\t\t\t// refund puts a stem below the baseline and through the gutter into the row\n\t\t\t\t\t\t// below, where either reads as that row's data.\n\t\t\t\t\t\t// Namespaced per mounted chart. `row-clip-${rowIndex}` is document-global, so two\n\t\t\t\t\t\t// instances — a split pane, or this panel beside anything reusing the scheme —\n\t\t\t\t\t\t// both define `row-clip-0`, and every colliding row resolves to whichever was\n\t\t\t\t\t\t// parsed first, whose plot width came from a differently sized pane. Silent\n\t\t\t\t\t\t// mis-clipping, no error.\n\t\t\t\t\t\tconst clipId = `${instanceId}-row-${rowIndex}`\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<g key={row.key}>\n\t\t\t\t\t\t\t\t<defs>\n\t\t\t\t\t\t\t\t\t<clipPath id={clipId}>\n\t\t\t\t\t\t\t\t\t\t<rect x={GUTTER} y={plotTop} width={plotWidth} height={Math.max(1, bottom - plotTop)} />\n\t\t\t\t\t\t\t\t\t</clipPath>\n\t\t\t\t\t\t\t\t</defs>\n\t\t\t\t\t\t\t\t<line x1={GUTTER} x2={GUTTER + plotWidth} y1={zeroY} y2={zeroY} stroke=\"currentColor\" strokeWidth={1} opacity={0.45} />\n\n\t\t\t\t\t\t\t\t{/* The row names itself, in the dead strip above its own band. Small\n\t\t\t\t\t\t\t\t multiples without in-place labels forfeit the thing they are for:\n\t\t\t\t\t\t\t\t scanning down the stack and knowing what each band is. The label\n\t\t\t\t\t\t\t\t used to live only in a wrapping legend whose order was not tied to\n\t\t\t\t\t\t\t\t vertical position. */}\n\t\t\t\t\t\t\t\t{/* Inside the band, not in the gutter above it. At `top - 6` the label's\n\t\t\t\t\t\t\t\t ascender sat four units below the PREVIOUS row's baseline and six\n\t\t\t\t\t\t\t\t above its own — bound by proximity to the wrong band, which is the\n\t\t\t\t\t\t\t\t one thing small multiples exist to get right. On row 0 it also ran\n\t\t\t\t\t\t\t\t straight through the campaign marker dots. */}\n\t\t\t\t\t\t\t\t{/* A small block of the row's colour, immediately before its name. This is\n\t\t\t\t\t\t\t\t the only place the mapping from colour to series is stated at full\n\t\t\t\t\t\t\t\t size and in position, so a reader who glances at a line can name it\n\t\t\t\t\t\t\t\t without consulting a legend elsewhere on the page. */}\n\t\t\t\t\t\t\t\t{/* Marked as furniture. It is the same colour as the row's data marks, so\n\t\t\t\t\t\t\t\t anything counting marks by fill — the render tests do — would otherwise\n\t\t\t\t\t\t\t\t count the label's swatch as a day that had an order. */}\n\t\t\t\t\t\t\t\t<rect data-swatch=\"\" x={GUTTER + 4} y={top + 3} width={8} height={8} rx={2} fill={stroke} />\n\t\t\t\t\t\t\t\t<text x={GUTTER + 16} y={top + 11} fontSize={AXIS_TYPE} fill=\"currentColor\" opacity={0.85} fontWeight={500}>\n\t\t\t\t\t\t\t\t\t{row.label}\n\t\t\t\t\t\t\t\t</text>\n\t\t\t\t\t\t\t\t{/* 0.5 put 11px type at about 3.9:1 on a white card, under the 4.5:1 floor for\n\t\t\t\t\t\t text this size. The fill's alpha was measured and documented; the type's\n\t\t\t\t\t\t was not. 0.72 clears it in both themes. */}\n\t\t\t\t\t\t<text x={GUTTER + plotWidth} y={top + 11} textAnchor=\"end\" fontSize={AXIS_TYPE} fill=\"currentColor\" opacity={0.72}>\n\t\t\t\t\t\t\t\t\t{row.source}{row.complete ? '' : ' · partial'}\n\t\t\t\t\t\t\t\t</text>\n\n\t\t\t\t\t\t\t\t{/* The axis top is what the scale REACHES, read back off the domain.\n\t\t\t\t\t\t\t\t Printing the raw peak beside a .nice()d scale gave three different\n\t\t\t\t\t\t\t\t numbers: a band topping out at 2,500, a label reading 2,356, and a\n\t\t\t\t\t\t\t\t position corresponding to neither. */}\n\t\t\t\t\t\t\t\t<text x={GUTTER - 6} y={y(y.domain()[1] as number) + 4} textAnchor=\"end\" fontSize={AXIS_TYPE} fill=\"currentColor\" opacity={0.7}>\n\t\t\t\t\t\t\t\t\t{formatValue(y.domain()[1] as number, row.unit, currency)}\n\t\t\t\t\t\t\t\t</text>\n\t\t\t\t\t\t\t\t{/* Formatted, like the top of the same axis. A literal \"0\" sat under \"$800.00\"\n\t\t\t\t\t\t or \"43.0%\" — the two ends of one axis written in different units. */}\n\t\t\t\t\t\t{/* The DOMAIN's floor, not a literal zero. The fix that was applied to the top of\n\t\t\t\t\t\t this axis was never applied to its bottom, so a row containing a refund — where\n\t\t\t\t\t\t `lowest` is negative — printed \"US$0\" at the band floor, and everything above\n\t\t\t\t\t\t that rule read as positive. */}\n\t\t\t\t\t\t<text x={GUTTER - 6} y={bottom + 4} textAnchor=\"end\" fontSize={AXIS_TYPE} fill=\"currentColor\" opacity={0.7}>\n\t\t\t\t\t\t\t{formatValue(y.domain()[0] as number, row.unit, currency)}\n\t\t\t\t\t\t</text>\n\n\t\t\t\t\t\t\t\t{/* One alpha, measured, not tied to hover.\n\t\t\t\t\t\t\t\t A previous comment claimed 0.26/0.34 cleared 3:1 and it did not —\n\t\t\t\t\t\t\t\t computing the composite against the card gives 1.80:1 and 2.22:1 in\n\t\t\t\t\t\t\t\t light. 0.48 gives 3.33:1 light and 4.16:1 dark, so one number serves\n\t\t\t\t\t\t\t\t both themes. It is constant because a quantity that brightens when\n\t\t\t\t\t\t\t\t the pointer enters the chart is jitter, not information. */}\n\t\t\t\t\t\t\t\t<g clipPath={`url(#${clipId})`}>\n\t\t\t\t\t\t\t\t{/* Behind everything, and quiet enough to read as context rather than as a\n\t\t\t\t\t\t\t\t second answer. Dashed so it is distinguishable in a screenshot and to\n\t\t\t\t\t\t\t\t anyone who cannot separate two greys. */}\n\t\t\t\t\t\t\t\t{ghost && (\n\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\td={ghost}\n\t\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\t\tstroke={mark('chart.ghost')}\n\t\t\t\t\t\t\t\t\t\tstrokeWidth={1}\n\t\t\t\t\t\t\t\t\t\tstrokeDasharray=\"2 3\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t{/* NO FILLED REGION. The two lines are enough.\n\t\t\t\t\n\t\t\t\t The shaded area between them encoded the ABSOLUTE gap, and under a roughly flat\n\t\t\t\t coverage rate the absolute gap is just the traffic curve scaled down — at 20%\n\t\t\t\t coverage it is 0.8 times traffic and tracks it exactly. So every busy day drew a\n\t\t\t\t wider alarm than every quiet day with no change whatever in the instrument. The\n\t\t\t\t coverage row directly beneath this one already makes that argument, in its own\n\t\t\t\t comment, to justify its own existence; it convicts the region above it.\n\t\t\t\t\n\t\t\t\t What the region uniquely showed — the SIZE of the loss — is stated at reading size\n\t\t\t\t in the caution card above the chart and is sortable in the table below it. The\n\t\t\t\t dashed line stays: it shows the lossier source's own level, and the collapse\n\t\t\t\t detector, the missed-pageviews sentence and the spoken summary all read from it. */}\n\t\t\t\t\t\t\t\t{/* The region's BOUNDARY, drawn because the registry promises it.\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t A fill this light cannot carry 3:1 and is not supposed to — what it\n\t\t\t\t\t\t\t\t covers has to stay readable. MARKS therefore lets it sit under the\n\t\t\t\t\t\t\t\t floor only by naming a `boundary` mark that does clear it, and until\n\t\t\t\t\t\t\t\t now that boundary was declared and never drawn: a contract honoured in\n\t\t\t\t\t\t\t\t the test and not in the picture, which is the exact defect the\n\t\t\t\t\t\t\t\t registry was built to stop. This is the line that makes the region's\n\t\t\t\t\t\t\t\t extent perceivable. */}\n\t\t\t\t\t\t\t\t{shortfallLine && (\n\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\td={shortfallLine}\n\t\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\t\tstroke={mark('chart.lossy')}\n\t\t\t\t\t\t\t\t\t\tstrokeWidth={1.5}\n\t\t\t\t\t\t\t\t\t\tstrokeDasharray=\"6 4\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t)}\n\n\n\n\t\t\t\t\t\t\t\t{zeroes.map((p) => {\n\t\t\t\t\t\t\t\t\tconst cx = at(p)\n\t\t\t\t\t\t\t\t\tif (!Number.isFinite(cx)) return null\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<rect\n\t\t\t\t\t\t\t\t\t\t\tkey={`zero-${p.date}`}\n\t\t\t\t\t\t\t\t\t\t\tx={Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2))}\n\t\t\t\t\t\t\t\t\t\t\t// Above the baseline, taller than it, and darker. At 1px and 0.3 opacity\n\t\t\t\t\t\t\t\t\t\t\t// sitting ON a rule drawn at 0.45, the distinction this exists to keep\n\t\t\t\t\t\t\t\t\t\t\t// was encoded below the baseline's own visibility.\n\t\t\t\t\t\t\t\t\t\t\ty={zeroY - 3}\n\t\t\t\t\t\t\t\t\t\t\twidth={stemWidth}\n\t\t\t\t\t\t\t\t\t\t\theight={3}\n\t\t\t\t\t\t\t\t\t\t\tfill={stroke}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\n\t\t\t\t\t\t\t\t{stems.map((p) => {\n\t\t\t\t\t\t\t\t\tconst cx = at(p)\n\t\t\t\t\t\t\t\t\t// Not `top`: that is the row band's origin, four lines up in the same scope.\n\t\t\t\t\t\t\t\t\tconst headY = y(p.value as number)\n\t\t\t\t\t\t\t\t\tif (!Number.isFinite(cx) || !Number.isFinite(headY)) return null\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<rect\n\t\t\t\t\t\t\t\t\t\t\tkey={p.date}\n\t\t\t\t\t\t\t\t\t\t\t// Clamped to the plot. The first and last day sit exactly on the\n\t\t\t\t\t\t\t\t\t\t\t// gutter edges, so a centred stem hung up to half its width outside\n\t\t\t\t\t\t\t\t\t\t\t// them — the first one into the y-axis label column.\n\t\t\t\t\t\t\t\t\t\t\tx={Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2))}\n\t\t\t\t\t\t\t\t\t\t\ty={Math.min(headY, zeroY)}\n\t\t\t\t\t\t\t\t\t\t\twidth={stemWidth}\n\t\t\t\t\t\t\t\t\t\t\t// Floored at 1.5px: a day whose value rounds to nothing on this\n\t\t\t\t\t\t\t\t\t\t\t// scale still happened, and drawing it as zero height would say\n\t\t\t\t\t\t\t\t\t\t\t// it did not.\n\t\t\t\t\t\t\t\t\t\t\theight={Math.max(1.5, Math.abs(zeroY - headY))}\n\t\t\t\t\t\t\t\t\t\t\tfill={stroke}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\n\t\t\t\t\t\t\t\t{path && <path\n\t\t\t\t\t\t\t\t\td={path}\n\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\tstroke={stroke}\n\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t// Dashed only where the LINE's own source misses things. A row whose\n\t\t\t\t\t\t\t\t\t// line is complete stays solid even when it carries a blind-spot fill.\n\t\t\t\t\t\t\t\t\t//\n\t\t\t\t\t\t\t\t\t// Opaque, too. Opacity used to carry incompleteness alongside the dash,\n\t\t\t\t\t\t\t\t\t// which dropped an incomplete line to 2.6:1 on the light theme — one\n\t\t\t\t\t\t\t\t\t// encoding doing double duty and losing contrast for it.\n\t\t\t\t\t\t\t\t\tstrokeDasharray={row.complete ? undefined : '6 4'}\n\t\t\t\t\t\t\t\t/>}\n\t\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\n\t\t\t\t\t{/* Campaign sends and other dated events, drawn through every row at once — which\n\t\t\t\t\t is the only way to see whether one moved the others. */}\n\t\t\t\t\t{markers.map((marker) => {\n\t\t\t\t\t\tconst at = x(new Date(`${marker.date}T00:00:00Z`))\n\t\t\t\t\t\tif (!Number.isFinite(at)) return null\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<g key={`${marker.date}-${marker.label}`}>\n\t\t\t\t\t\t\t\t<line\n\t\t\t\t\t\t\t\t\tx1={at}\n\t\t\t\t\t\t\t\t\tx2={at}\n\t\t\t\t\t\t\t\t\ty1={TOP_PAD - 6}\n\t\t\t\t\t\t\t\t\ty2={TOP_PAD + series.length * rowHeight - BASELINE_GUTTER}\n\t\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\t\tstrokeWidth={1.25}\n\t\t\t\t\t\t\t\t\tstrokeDasharray=\"4 3\"\n\t\t\t\t\t\t\t\t\topacity={0.5}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<circle cx={at} cy={TOP_PAD - 8} r={3} fill=\"currentColor\" opacity={0.7} />\n\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\n\t\t\t\t\t{/* The selection. Drawn as two dimmed flanks rather than a tinted middle, so the\n\t\t\t\t\t chosen span keeps the card's own background and stays the most legible part of\n\t\t\t\t\t the chart — a tint over the data would fight the marks it is meant to frame. */}\n\t\t\t\t\t{/* Bounds-checked against the CURRENT dates. `brushed` holds indices, and a commit\n\t\t\t\t\t changes the range underneath it — so after narrowing, indices from the old\n\t\t\t\t\t window pointed past the end of the new one, `dates[i]` was undefined, and both\n\t\t\t\t\t rects rendered width=\"NaN\". */}\n\t\t\t\t\t{brushed && brushed[1] > brushed[0] && brushed[1] < dates.length && (\n\t\t\t\t\t\t<g aria-hidden=\"true\">\n\t\t\t\t\t\t\t{/* Scrimmed toward the CARD, not with currentColor. currentColor is near-white\n\t\t\t\t\t\t\t on a dark card, so the \"dimmed\" flanks became the brightest part of the\n\t\t\t\t\t\t\t chart and the selection the darkest — the inverse of the intent, in half\n\t\t\t\t\t\t\t the installs. */}\n\t\t\t\t\t\t\t<rect\n\t\t\t\t\t\t\t\tx={GUTTER}\n\t\t\t\t\t\t\t\ty={TOP_PAD - 8}\n\t\t\t\t\t\t\t\twidth={Math.max(0, x(new Date(`${dates[brushed[0]]}T00:00:00Z`)) - GUTTER)}\n\t\t\t\t\t\t\t\theight={series.length * rowHeight - 10}\n\t\t\t\t\t\t\t\tfill=\"var(--card-bg-color, #ffffff)\"\n\t\t\t\t\t\t\t\topacity={0.72}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<rect\n\t\t\t\t\t\t\t\tx={x(new Date(`${dates[brushed[1]]}T00:00:00Z`))}\n\t\t\t\t\t\t\t\ty={TOP_PAD - 8}\n\t\t\t\t\t\t\t\twidth={Math.max(0, GUTTER + plotWidth - x(new Date(`${dates[brushed[1]]}T00:00:00Z`)))}\n\t\t\t\t\t\t\t\theight={series.length * rowHeight - 10}\n\t\t\t\t\t\t\t\tfill=\"var(--card-bg-color, #ffffff)\"\n\t\t\t\t\t\t\t\topacity={0.72}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</g>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{hoveredDate && (\n\t\t\t\t\t\t<line\n\t\t\t\t\t\t\tx1={x(new Date(`${hoveredDate}T00:00:00Z`))}\n\t\t\t\t\t\t\tx2={x(new Date(`${hoveredDate}T00:00:00Z`))}\n\t\t\t\t\t\t\ty1={TOP_PAD - 6}\n\t\t\t\t\t\t\ty2={TOP_PAD + series.length * rowHeight - 18}\n\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\tstrokeWidth={1}\n\t\t\t\t\t\t\topacity={0.8}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* The shared date axis, once, at the bottom.\n\t\t\t\t\t As many ticks as the measured width affords — it was fixed at three regardless,\n\t\t\t\t\t so a 1400px pane got the same three labels as a 400px one, and the one thing\n\t\t\t\t\t measuring the chart makes cheap went unused. ~90px per label keeps them from\n\t\t\t\t\t touching at the longest label this formatter produces. */}\n\t\t\t\t\t{tickIndexes.map((index, position) => {\n\t\t\t\t\t\tconst date = dates[index]\n\t\t\t\t\t\tif (!date) return null\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<text\n\t\t\t\t\t\t\t\tkey={date}\n\t\t\t\t\t\t\t\tx={x(new Date(`${date}T00:00:00Z`))}\n\t\t\t\t\t\t\t\ty={height - 6}\n\t\t\t\t\t\t\t\ttextAnchor={position === 0 ? 'start' : position === tickIndexes.length - 1 ? 'end' : 'middle'}\n\t\t\t\t\t\t\t\tfontSize={AXIS_TYPE}\n\t\t\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\t\t\topacity={0.72}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{tickLabel(new Date(`${date}T00:00:00Z`))}\n\t\t\t\t\t\t\t</text>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</svg>\n\n\t\t\t\t{/* Anchored to the crosshair the svg already draws. Rendered outside the svg so it is\n\t\t\t\t ordinary DOM — an SVG <foreignObject> is the alternative and is clipped by the\n\t\t\t\t viewBox, which is exactly the edge case the flip exists to handle. */}\n\t\t\t\t{hoveredDate && (\n\t\t\t\t\t<HoverCard\n\t\t\t\t\t\tx={x(new Date(`${hoveredDate}T00:00:00Z`))}\n\t\t\t\t\t\tpaneWidth={measured}\n\t\t\t\t\t\tdate={tickLabel(new Date(`${hoveredDate}T00:00:00Z`))}\n\t\t\t\t\t\trows={series.map((row) => {\n\t\t\t\t\t\t\tconst point = row.points.find((p) => p.date === hoveredDate)\n\t\t\t\t\t\t\tconst seen = row.shortfall?.points.find((p) => p.date === hoveredDate)\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\tkey: row.key,\n\t\t\t\t\t\t\t\tlabel: row.label,\n\t\t\t\t\t\t\t\tcolor: SERIES[colorFor(row.source, row.unit, row.color)],\n\t\t\t\t\t\t\t\t// A dash, never a zero: a day this source did not measure is not a day it\n\t\t\t\t\t\t\t\t// measured nothing, and the whole package turns on that distinction.\n\t\t\t\t\t\t\t\tvalue: point && point.value !== null ? formatValue(point.value, row.unit, currency) : '—',\n\t\t\t\t\t\t\t\tseen: seen && seen.value !== null\n\t\t\t\t\t\t\t\t\t? `${row.shortfall?.source} ${formatValue(seen.value, row.unit, currency)}`\n\t\t\t\t\t\t\t\t\t: null,\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t})}\n\t\t\t\t\t\tevents={markers.filter((m) => m.date === hoveredDate).map((m) => m.label)}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/* The readout. Every series at the hovered date, so the co-movement question is answered\n\t\t\t in numbers as well as in shape. */}\n\t\t\t<div style={readoutRow} aria-live={steppedByKeyboard ? 'polite' : 'off'}>\n\t\t\t\t<Text size={0} weight=\"medium\">\n\t\t\t\t\t{/* The selection is confirmed where the gesture happens. The only confirmation was\n\t\t\t\t\t a line above the tab strip, the full height of the panel away from the drag. */}\n\t\t\t\t\t{brushed && brushed[1] > brushed[0] && brushed[1] < dates.length\n\t\t\t\t\t\t? `${tickLabel(new Date(`${dates[brushed[0]]}T00:00:00Z`))} – ${tickLabel(new Date(`${dates[brushed[1]]}T00:00:00Z`))} · ${brushed[1] - brushed[0] + 1} days`\n\t\t\t\t\t\t: hoveredDate\n\t\t\t\t\t\t\t? tickLabel(new Date(`${hoveredDate}T00:00:00Z`))\n\t\t\t\t\t\t\t// Nothing, rather than an instruction. This is the readout slot: it exists to\n\t\t\t\t\t\t\t// hold the hovered day, and filling it with \"Hover the chart to read a day\"\n\t\t\t\t\t\t\t// meant the place where data appears permanently showed a sentence instead —\n\t\t\t\t\t\t\t// one that is also false on a touch screen. The row keeps its reserved height,\n\t\t\t\t\t\t\t// so the page still does not move when a day is picked up.\n\t\t\t\t\t\t\t: ''}\n\t\t\t\t</Text>\n\t\t\t\t{/* The same figures the floating card shows, kept here for the keyboard path and for\n\t\t\t\t assistive technology: the card is positioned from a pointer coordinate, and a\n\t\t\t\t reader stepping the chart with arrow keys needs the values announced in the live\n\t\t\t\t region rather than drawn next to a crosshair they cannot see. Visually redundant\n\t\t\t\t with the card by design — this is the accessible copy, not a second display. */}\n\t\t\t\t{hoveredDate && series.map((row) => {\n\t\t\t\t\tconst point = row.points.find((p) => p.date === hoveredDate)\n\t\t\t\t\tconst seen = row.shortfall?.points.find((p) => p.date === hoveredDate)\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<Text key={row.key} size={0} muted>\n\t\t\t\t\t\t\t{row.label}:{' '}\n\t\t\t\t\t\t\t{point && point.value !== null ? formatValue(point.value, row.unit, currency) : '—'}\n\t\t\t\t\t\t\t{seen && seen.value !== null && (\n\t\t\t\t\t\t\t\t<> · {row.shortfall?.source} saw {formatValue(seen.value, row.unit, currency)}</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t\t{hoveredDate && markers.filter((m) => m.date === hoveredDate).map((m) => (\n\t\t\t\t\t<Text key={m.label} size={0}>· {m.label}</Text>\n\t\t\t\t))}\n\t\t\t</div>\n\n\t\t\t{/* ONE legend, and every entry drawn in the colour and shape of the mark it names.\n\t\t\t\n\t\t\t There used to be three. Each row inside the plot carried a coloured swatch beside its\n\t\t\t name; the hover card carried a second set, also coloured; and this row underneath drew\n\t\t\t ─── ╌╌╌ ▮▮▮ as TEXT GLYPHS inside a muted <Text>, so every swatch here came out grey\n\t\t\t while the chart drew those same series blue, amber and green. A reader reported it as\n\t\t\t \"the legends don't match the graphs\", which is exactly what it was.\n\t\t\t\n\t\t\t The per-series rows are gone rather than recoloured: the in-plot legend already names\n\t\t\t every row, at full size, in colour, beside the line it belongs to. What is left here is\n\t\t\t only the marks that legend CANNOT carry — the ones drawn across rows or behind them,\n\t\t\t which had no legend entry anywhere. */}\n\t\t\t<div style={legendRow}>\n\t\t\t\t{series.some((row) => row.shortfall) && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{/* The label existed on the Series type all along and was rendered nowhere, so\n\t\t\t\t\t\t the dashed orange line along the top of the shaded area was an unexplained\n\t\t\t\t\t\t mark that read as a second, disagreeing traffic measurement. */}\n\t\t\t\t\t\t<LegendKey\n\t\t\t\t\t\t\tswatch={<span style={legendDash(shortfallColour(series))} />}\n\t\t\t\t\t\t\tlabel={`${shortfallLabel(series)} — the dashed line`}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t\t{series.some((row) => (row.comparison?.length ?? 0) > 1) && (\n\t\t\t\t\t<LegendKey\n\t\t\t\t\t\tswatch={<span style={legendDash(mark('chart.ghost'))} />}\n\t\t\t\t\t\tlabel=\"The same days in the window you are comparing against\"\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t\t{markers.length > 0 && (\n\t\t\t\t\t<LegendKey\n\t\t\t\t\t\tswatch={<span style={legendMarker}><span style={legendMarkerDot} /></span>}\n\t\t\t\t\t\tlabel=\"Dashed rules with a dot mark campaign sends\"\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t\t{onBrush && (\n\t\t\t\t\t// The refusal replaces the instruction rather than sitting beside it, and it is a\n\t\t\t\t\t// live region: the reader has just dragged and is looking at the chart, not at the\n\t\t\t\t\t// small print under it.\n\t\t\t\t\t<Text size={0} muted={!tooShort} aria-live=\"polite\">\n\t\t\t\t\t\t{tooShort\n\t\t\t\t\t\t\t? `That span was too short to apply — the chart needs at least ${MIN_BRUSH_DAYS} days. Drag a little wider.`\n\t\t\t\t\t\t\t: 'Drag across at least three days — or hold shift and use the arrow keys, then Enter — to narrow every panel to that span.'}\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</Stack>\n\t)\n}\n\n/**\n * One legend entry: the mark as it is actually drawn, then what it means.\n *\n * The swatch is a real styled element rather than a text glyph, which is the whole point — a glyph\n * inside a muted <Text> inherits the muted colour, so the old legend was grey while the chart was\n * not.\n */\nfunction LegendKey({ swatch, label }: { swatch: React.ReactElement; label: string }): React.ReactElement {\n\treturn (\n\t\t<span style={legendKey}>\n\t\t\t<span aria-hidden=\"true\">{swatch}</span>\n\t\t\t<Text size={0} muted>{label}</Text>\n\t\t</span>\n\t)\n}\n\n/** Swatch and label, on one baseline. */\nconst legendKey: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 6 }\n\n/**\n * A dashed line swatch, in the colour the line is drawn in.\n *\n * @param colour - the stroke\n */\nfunction legendDash(colour: string): React.CSSProperties {\n\treturn {\n\t\tdisplay: 'inline-block',\n\t\twidth: 18,\n\t\theight: 0,\n\t\tborderTop: `2px dashed ${colour}`,\n\t}\n}\n\n/**\n * A filled-region swatch: the fill, with the boundary that makes its extent visible.\n *\n * @param fill - the region colour, at the alpha it is drawn at\n * @param edge - the boundary stroke\n */\nfunction legendBlock(fill: string, edge: string): React.CSSProperties {\n\treturn {\n\t\tdisplay: 'inline-block',\n\t\twidth: 18,\n\t\theight: 10,\n\t\tbackground: fill,\n\t\tborderTop: `1.5px dashed ${edge}`,\n\t}\n}\n\n/**\n * The campaign-marker swatch: a dashed rule with the dot that sits on top of it.\n *\n * Drawn in `currentColor` at the marker's own opacity, because that is what the marker is drawn in\n * (CrossSourceTimeline's marker path, `stroke=\"currentColor\" opacity={0.5}` with a filled dot at\n * 0.7). It previously borrowed `chart.grid` — a different colour at 0.18 alpha, around 1.17:1 on a\n * white card, and with no dot at all under a label that promises one.\n */\nconst legendMarker: React.CSSProperties = {\n\tdisplay: 'inline-block',\n\tposition: 'relative',\n\twidth: 1,\n\theight: 12,\n\tborderLeft: '1px dashed currentColor',\n\t// The rule's OWN weight. The chart draws the line at 0.5 and only the dot at 0.7, so putting 0.7\n\t// on the whole element made the legend's rule heavier than the rule it names — the same class of\n\t// mismatch this legend work exists to remove.\n\topacity: 0.5,\n}\n\n/**\n * The dot that sits on top of a campaign rule.\n *\n * A real element. It was a pair of box-shadows with zero spread, and a shadow with no spread\n * replicates the whole 1x12 border box rather than a point — so the \"dot\" rendered as a 2px x 12px\n * bar, sitting left of the rule instead of on it and overflowing 6px into the row above. The mark\n * it names is a 6px circle centred on the rule, 2px above its top.\n */\nconst legendMarkerDot: React.CSSProperties = {\n\tposition: 'absolute',\n\ttop: -3,\n\tleft: -2.5,\n\twidth: 6,\n\theight: 6,\n\tborderRadius: '50%',\n\tbackground: 'currentColor',\n\topacity: 0.7,\n}\n\n/**\n * Which series key the shortfall is drawn in, so the legend and the plot cannot disagree.\n *\n * @param series - the stack\n */\nfunction shortfallKey(series: readonly Series[]): SeriesKey {\n\tconst row = series.find((r) => r.shortfall)\n\treturn colorFor(row?.shortfall?.source ?? 'GA4', row?.unit ?? 'count', row?.shortfall?.color)\n}\n\n/**\n * The shortfall line's colour.\n *\n * @param series - the stack\n */\nfunction shortfallColour(series: readonly Series[]): string {\n\treturn SERIES[shortfallKey(series)]\n}\n\n/**\n * What the shortfall line is called.\n *\n * `Series['shortfall']['label']` is set by the panel and, until now, rendered nowhere in the\n * package — so the mark it names had no legend entry on any surface.\n *\n * @param series - the stack\n */\nfunction shortfallLabel(series: readonly Series[]): string {\n\treturn series.find((r) => r.shortfall)?.shortfall?.label ?? 'Seen by your analytics'\n}\n\n/** Chart frame. */\nconst frameStyle: React.CSSProperties = { width: '100%', overflow: 'hidden', position: 'relative' }\n\n/**\n * The hover readout: every series at one date, wrapping on a narrow pane.\n *\n * The reserved height covers the wrapped case, not the empty one. At `minHeight: 18` the row grew\n * from one line to four or five on pointer-enter and shrank back on leave, pushing the legend, the\n * caveats and the disclosure sixty pixels down and up again — the page moving while the reader\n * tracks a crosshair across it. Reserving the maximum costs a band of empty space and buys a chart\n * that holds still.\n */\nconst readoutRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: SPACE.block,\n\tflexWrap: 'wrap',\n\talignItems: 'baseline',\n\tminHeight: 54,\n}\n\n\n/** Legend, wrapping rather than overflowing. */\nconst legendRow: React.CSSProperties = {\n\tdisplay: 'flex',\n\tgap: SPACE.block,\n\tflexWrap: 'wrap',\n\talignItems: 'center',\n}\n","/**\n * Where the latest week sits among the weeks before it.\n *\n * A percentage change is the wrong summary at a foundry's volumes. Seven orders against four is\n * \"+75%\", which is the largest type on the default tab and moves twenty-five points if one more\n * person buys. The tool's own report builders refuse far better-supported claims than that.\n *\n * A RANK does not have the problem. It makes no claim about magnitude, it is stable under the\n * one-order wobble that makes the ratio useless, and it answers the question a foundry owner\n * actually opens the panel with — was this week normal? — in a form that needs no arithmetic.\n *\n * It is computed from the exact daily series the envelope already carries, so it costs no request\n * and inherits none of GA4's loss: orders and revenue come from the foundry's own records.\n */\n\n/** One day of a quantity. `null` means the day was not measured, which is not the same as zero. */\nexport interface DailyValue {\n\tdate: string\n\tvalue: number | null\n}\n\n/** Where the most recent week landed among the weeks available to compare it with. */\nexport interface WeeklyRank {\n\t/** 1 is the best week in the window. */\n\trank: number\n\t/** How many whole weeks were compared, including this one. */\n\tof: number\n\t/** The most recent week's total. */\n\tvalue: number\n}\n\n/**\n * How many whole weeks must be available before a rank is worth stating.\n *\n * Four. \"Second of three\" is barely a ranking and reads as more certain than it is; at four the\n * statement starts to carry information about what normal looks like. A Month range yields exactly\n * four, so the default view can say something; a Quarter yields thirteen.\n */\nexport const MIN_WEEKS_TO_RANK = 4\n\n/**\n * Rank the most recent whole week against the whole weeks preceding it.\n *\n * Buckets backwards from the END of the series, so the latest week is always complete rather than\n * whatever fragment the calendar left. A week containing ANY unmeasured day is dropped rather than\n * summed — a partial week would compete as though it were a quiet one, which is the absent-versus-\n * zero error this package exists to avoid, wearing a different hat.\n *\n * Returns null when fewer than `MIN_WEEKS_TO_RANK` whole weeks survive that rule.\n *\n * @param series - daily values in ascending date order\n */\nexport function weeklyRank(series: readonly DailyValue[]): WeeklyRank | null {\n\tif (series.length < 7 * MIN_WEEKS_TO_RANK) return null\n\n\t// Backwards from the end: the most recent seven days are the week being ranked.\n\tconst weeks: Array<number | null> = []\n\tfor (let end = series.length; end - 7 >= 0; end -= 7) {\n\t\tconst slice = series.slice(end - 7, end)\n\t\t// All seven days measured, or the week does not compete.\n\t\tweeks.push(slice.some((d) => d.value === null) ? null : slice.reduce((sum, d) => sum + (d.value as number), 0))\n\t}\n\n\tconst latest = weeks[0]\n\tif (latest === null || latest === undefined) return null\n\n\tconst comparable = weeks.filter((w): w is number => w !== null)\n\tif (comparable.length < MIN_WEEKS_TO_RANK) return null\n\n\t// Ties share the better rank: two equal-best weeks are both \"1st\", which is what a reader\n\t// means by best. Counting strictly-greater weeks gives that for free.\n\tconst better = comparable.filter((w) => w > latest).length\n\n\treturn { rank: better + 1, of: comparable.length, value: latest }\n}\n\n/**\n * The rank as a reader would say it.\n *\n * Deliberately plain: no \"percentile\", no \"quartile\". \"Best of the last 13 weeks\" is a sentence a\n * foundry owner can forward to a business partner without translating it first.\n *\n * @param rank - the result of weeklyRank\n */\nexport function describeRank(rank: WeeklyRank): string {\n\tconst ordinal = (n: number): string => {\n\t\tconst tens = n % 100\n\t\tif (tens >= 11 && tens <= 13) return `${n}th`\n\t\treturn `${n}${['th', 'st', 'nd', 'rd'][n % 10] ?? 'th'}`\n\t}\n\tif (rank.rank === 1) return `best of the last ${rank.of} weeks`\n\tif (rank.rank === rank.of) return `lowest of the last ${rank.of} weeks`\n\treturn `${ordinal(rank.rank)} best of the last ${rank.of} weeks`\n}\n","/**\n * How much of reality GA4 is seeing, measured against sources that are not lossy.\n *\n * GA4 is the only source here that misses things: consent refusal, ad-blocking, a tag that stops\n * firing, a data filter switched to Active. On Darden it has been reporting roughly a fifth of the\n * pageviews Vercel counts. Every panel built on it is therefore an undercount of unknown size, and\n * the tool's answer to that has been a caveat — accurate, and useless for deciding anything.\n *\n * The other sources overlap GA4 in places where both measure the same thing, and each of those\n * overlaps yields an independent estimate of the same quantity: GA4's capture rate. That is what\n * makes them worth combining. One ratio is a curiosity; three that agree are a measurement, and\n * three that disagree localise the fault.\n *\n * The estimates are deliberately NOT averaged into a single number. They measure different\n * populations and fail in different ways, and their disagreement carries more information than\n * their mean — so the model keeps them separate, picks the most trustworthy as the point estimate,\n * and uses the spread as the interval.\n */\n\n/** Where one estimate of the capture rate came from. */\nexport type CaptureBasis = 'orders' | 'pageviews' | 'email'\n\n/** One independent estimate of GA4's capture rate. */\nexport interface CaptureEstimate {\n\tbasis: CaptureBasis\n\t/** Fraction of reality GA4 saw, by this measure. Above 1 means GA4 counted more, not less. */\n\trate: number\n\t/** What GA4 reported. */\n\tobserved: number\n\t/** What the non-lossy source reported for the same thing. */\n\tactual: number\n\t/** How much weight this estimate deserves, and why, in words a panel can show. */\n\tnote: string\n}\n\n/**\n * How far apart two estimates must be before the disagreement is worth reporting.\n *\n * A fifth. Below that, the ratios differ for ordinary reasons — a crawler Vercel counted, an order\n * placed either side of midnight — and calling it a discrepancy would cry wolf on every load.\n */\nconst DISAGREEMENT_THRESHOLD = 0.2\n\n/**\n * The minimum denominator an estimate needs to mean anything.\n *\n * At seven orders a quarter, one order either way moves the orders-based rate by fourteen points.\n * Below this the ratio is noise wearing a decimal point, so it is excluded rather than shown with\n * a caveat nobody reads.\n */\nconst MIN_DENOMINATOR = 5\n\n/**\n * Build the orders-based estimate — the most trustworthy of the three.\n *\n * It compares GA4's `purchase` count against the orders that actually exist in Sanity. Same event,\n * same population, and the denominator is exact rather than merely better. Its weakness is size:\n * these foundries do single-digit orders a week, so it needs a long enough range to escape noise.\n */\nexport function fromOrders(ga4Purchases: number, sanityOrders: number): CaptureEstimate | null {\n\tif (sanityOrders < MIN_DENOMINATOR) return null\n\n\t// The sensitivity is on the SCREEN, not only in a comment four lines above the threshold. The\n\t// note used to end \"the denominator is exact\" — true of the order count and badly misleading\n\t// about the rate, which at seven orders moves fourteen points if one order lands either side of\n\t// midnight. This is the most trusted estimate, so it is the one that grosses up every corrected\n\t// figure in the tool; the reader is entitled to know how thin it is.\n\t/*\n\t * The NUMERATOR's sensitivity, which is the fragile side.\n\t *\n\t * This printed `1 / sanityOrders` and described it as the effect of \"one more or fewer order\" —\n\t * quoting the denominator's sensitivity and stating it wrongly. For a rate of k/n, one more\n\t * ORDER moves it by rate/n, which at a 20% capture and seven orders is about three points, not\n\t * fourteen. One more GA4 PURCHASE moves it by 1/n, which is the fourteen — and that is the side\n\t * that actually wobbles, because the numerator is the lossy one. The single caveat the reader\n\t * is given about the tool's most load-bearing ratio pointed at the steady half.\n\t */\n\tconst swing = Math.round((1 / sanityOrders) * 100)\n\treturn {\n\t\tbasis: 'orders',\n\t\trate: ga4Purchases / sanityOrders,\n\t\tobserved: ga4Purchases,\n\t\tactual: sanityOrders,\n\t\tnote: `GA4 purchases against the ${sanityOrders} orders that exist — the same event on both sides. `\n\t\t\t+ `Small numbers move it a long way: one more or fewer purchase seen by GA4 shifts this rate by about ${swing} points.`,\n\t}\n}\n\n/**\n * Build the pageview-based estimate.\n *\n * An UPPER bound on the capture rate, not a lower one. This comment said the opposite for the life\n * of the package, on the strength of a claim about Vercel that is not true.\n *\n * Vercel Web Analytics is not server-side. It is the `@vercel/analytics` client script — the same\n * kind of beacon GA4 uses, on a first-party path — so it is blocked too, just by fewer lists, and\n * it does not run for crawlers at all. The old reasoning was: Vercel over-counts (crawlers), so the\n * ratio understates GA4's true capture, so treat it as a floor. Both halves are wrong. Vercel is\n * itself an undercount of reality, so the real denominator is LARGER than the one used here, and\n * GA4/Vercel therefore flatters GA4 rather than maligning it.\n *\n * Which way this matters: when this estimate says GA4 sees a fifth of the traffic, the truth is a\n * fifth or less — never more. The direction of the caveat printed beside it was reversed.\n */\nexport function fromPageviews(ga4Pageviews: number, vercelPageviews: number): CaptureEstimate | null {\n\tif (vercelPageviews < MIN_DENOMINATOR) return null\n\treturn {\n\t\tbasis: 'pageviews',\n\t\trate: ga4Pageviews / vercelPageviews,\n\t\tobserved: ga4Pageviews,\n\t\tactual: vercelPageviews,\n\t\tnote: 'GA4 pageviews against Vercel’s. Read it as a ceiling: Vercel’s own counter is a script too — blocked by fewer lists than GA4, but blocked — so real traffic is higher than both and GA4’s true share is this or less.',\n\t}\n}\n\n/**\n * Build the email-based estimate.\n *\n * A known-size cohort: Mailchimp knows exactly how many people clicked through, and GA4 should see\n * a session for each. It measures the capture rate on traffic that is tagged and arriving from a\n * single known source, so a shortfall here that the others do not show points at attribution — a\n * tagging or redirect problem — rather than at measurement generally.\n */\nexport function fromEmail(ga4Users: number, mailchimpClicks: number): CaptureEstimate | null {\n\tif (mailchimpClicks < MIN_DENOMINATOR) return null\n\treturn {\n\t\tbasis: 'email',\n\t\trate: ga4Users / mailchimpClicks,\n\t\tobserved: ga4Users,\n\t\tactual: mailchimpClicks,\n\t\tnote: 'Distinct people GA4 saw arriving from email, against the distinct subscribers Mailchimp says clicked. Both sides count people, so the comparison holds — but it measures tagging as much as capture.',\n\t}\n}\n\n/**\n * A rough interval for a single rate, from how few events it was measured on.\n *\n * Two standard errors of a binomial proportion, clamped to the unit interval. At one GA4 purchase\n * against seven orders the honest range is enormous, and printing that is the point: the figure is\n * doing real work — it grosses up every corrected number in the tool — on a handful of events.\n *\n * @param estimate - the single estimate the model has\n */\nexport function samplingInterval(estimate: CaptureEstimate): { low: number; high: number } {\n\tconst n = estimate.actual\n\tif (!Number.isFinite(n) || n <= 0) return { low: estimate.rate, high: estimate.rate }\n\t/*\n\t * The interval is built around the RATE, not around a clamped copy of it.\n\t *\n\t * `p` was clamped to 0..1 for the variance — correct, a proportion's variance is only defined\n\t * there — and then the bounds were clamped to 0..1 as well. A rate above 1 is explicitly\n\t * supported here (\"above 1 means GA4 counted more, not less\"), so a 140% capture rate came back\n\t * with a range of 91% to 100%: a point estimate outside its own interval, printed as such.\n\t *\n\t * The variance still uses the clamped proportion; only the bounds follow the rate.\n\t */\n\tconst p = Math.min(1, Math.max(0, estimate.rate))\n\tconst error = 2 * Math.sqrt(Math.max(p * (1 - p), 0.02) / n)\n\treturn { low: Math.max(0, estimate.rate - error), high: estimate.rate + error }\n}\n\n/** The combined view of GA4's capture rate. */\nexport interface CaptureModel {\n\t/** Every estimate that had a usable denominator, best first. */\n\testimates: CaptureEstimate[]\n\t/** The rate to gross up by, or null when nothing was measurable. */\n\trate: number | null\n\t/** The plausible range for that rate. Equal to `rate` when only one estimate exists. */\n\tlow: number | null\n\thigh: number | null\n\t/**\n\t * What the disagreement between estimates implies, or null when they agree or only one exists.\n\t * This is the output worth reading when it is present: it says WHERE the problem is.\n\t */\n\tdiscrepancy: string | null\n}\n\n/** Order of trust. Orders beat pageviews because they compare the same event on both sides. */\nconst TRUST: CaptureBasis[] = ['orders', 'email', 'pageviews']\n\n/**\n * Combine whatever estimates were measurable.\n *\n * @param candidates - the estimates, any of which may be null when its denominator was too small\n */\nexport function captureModel(candidates: Array<CaptureEstimate | null>): CaptureModel {\n\tconst estimates = candidates\n\t\t.filter((e): e is CaptureEstimate => e !== null)\n\t\t.sort((a, b) => TRUST.indexOf(a.basis) - TRUST.indexOf(b.basis))\n\n\tif (estimates.length === 0) {\n\t\treturn { estimates, rate: null, low: null, high: null, discrepancy: null }\n\t}\n\n\tconst rates = estimates.map((e) => e.rate)\n\t// The point estimate is the most trusted single measurement, NOT the mean. Averaging a\n\t// same-event ratio against a not-like-for-like one produces a number that is neither, and the\n\t// pageview ratio would drag it down for reasons that are definitional rather than real.\n\tconst rate = estimates[0]!.rate\n\t/*\n\t * A single estimate gets a SAMPLING interval, not a zero-width one.\n\t *\n\t * `low` and `high` were the min and max across estimates, so with one estimate they both equal\n\t * the point — and the panel rendered \"~2,499\" with the subtitle \"2,499 to 2,499\" and an\n\t * Estimated badge. A zero-width interval on the one quantity this package exists to call\n\t * uncertain. On a Week range only the pageview estimate clears its denominator, so that was the\n\t * ordinary case rather than an edge one.\n\t *\n\t * The interval here is the normal approximation to a binomial proportion — the observed count\n\t * out of the true one — which is the actual reason this number is uncertain at these volumes.\n\t * It is deliberately not exact: the point is to stop printing a false precision, not to claim a\n\t * different one.\n\t */\n\tconst spread = estimates.length > 1\n\t\t? { low: Math.min(...rates), high: Math.max(...rates) }\n\t\t: samplingInterval(estimates[0]!)\n\n\treturn { estimates, rate, low: spread.low, high: spread.high, discrepancy: describeDisagreement(estimates) }\n}\n\n/**\n * Say what a disagreement between estimates means.\n *\n * Each pairing fails in a characteristic way, and naming the pattern is the whole value of holding\n * three estimates rather than one — it turns \"something is wrong\" into \"this specific thing is\n * wrong\", which is the difference between a caveat and an instruction.\n */\nfunction describeDisagreement(estimates: CaptureEstimate[]): string | null {\n\tif (estimates.length < 2) return null\n\n\tconst by = (basis: CaptureBasis) => estimates.find((e) => e.basis === basis)\n\tconst orders = by('orders')\n\tconst pageviews = by('pageviews')\n\tconst email = by('email')\n\n\tif (orders && pageviews && Math.abs(orders.rate - pageviews.rate) > DISAGREEMENT_THRESHOLD) {\n\t\treturn orders.rate > pageviews.rate\n\t\t\t? 'GA4 is capturing purchases far better than pageviews. That points at the pageview comparison rather than at a measurement failure — Vercel counts crawlers and prefetches GA4 never sees. Conversion figures here are more trustworthy than traffic figures.'\n\t\t\t: 'GA4 is capturing pageviews but missing purchases. That is not consent or ad-blocking, which would cost both equally — look at the purchase event itself: whether it fires on a page some buyers never reach, or is blocked at checkout.'\n\t}\n\n\t// The everyday pair. At seven orders a quarter the orders estimate is below its minimum on Week\n\t// and Month, so email-against-pageviews is usually the only comparison there is — and it was the\n\t// one pairing with no diagnosis at all, returning null and telling the reader nothing while two\n\t// contradictory percentages sat side by side under \"How much GA4 is seeing\".\n\tif (email && pageviews && Math.abs(email.rate - pageviews.rate) > DISAGREEMENT_THRESHOLD) {\n\t\treturn email.rate < pageviews.rate\n\t\t\t? 'Far fewer people are arriving from your mailing list than GA4’s general capture would predict. That points at the campaign links rather than at measurement — check they carry UTM tags, and that no redirect is stripping them.'\n\t\t\t: 'Email traffic is over-represented against GA4’s general capture rate, which usually means campaign links are tagged while ordinary traffic is not — so email looks larger than it is.'\n\t}\n\n\tif (email && orders && Math.abs(email.rate - orders.rate) > DISAGREEMENT_THRESHOLD) {\n\t\treturn email.rate < orders.rate\n\t\t\t? 'Traffic from email is arriving less often than GA4’s general capture rate would predict. That is an attribution problem rather than a measurement one — check the campaign links for missing UTMs, or a redirect stripping them.'\n\t\t\t: 'Email traffic is over-represented against GA4’s general capture rate, which usually means campaign links are tagged while ordinary traffic is not, so email looks larger than it is.'\n\t}\n\n\treturn null\n}\n\n/**\n * Gross a GA4 count up to an estimated true value.\n *\n * Returns null when there is no usable rate, so the caller reports the raw count rather than\n * inventing one — an estimate with no basis is worse than an honest undercount.\n *\n * @param observed - what GA4 counted\n * @param model - the capture model for the same range\n */\nexport function grossUp(\n\tobserved: number,\n\tmodel: CaptureModel,\n\t/**\n\t * Which bases may be used. Defaults to every one, which is right only when the quantity being\n\t * grossed up is the same kind of thing the estimate measured.\n\t */\n\tadmissible: CaptureBasis[] = ['orders', 'email', 'pageviews'],\n): { value: number; low: number; high: number; basis: CaptureBasis; rate: number } | null {\n\t/*\n\t * The estimate has to measure the same kind of loss as the quantity being corrected.\n\t *\n\t * Sessions were grossed up by `model.rate`, which is the most trusted estimate — the ORDERS one\n\t * whenever it exists. That is purchase-event capture, and this file argues at length that\n\t * purchase capture and traffic capture fail independently: a checkout on a third-party domain, a\n\t * purchase event without a value, a consent banner that gates one and not the other. So a broken\n\t * purchase tag was laundered into a multiplier on the traffic figure, under a heading reading\n\t * \"Sessions, corrected for what GA4 misses\".\n\t */\n\t/*\n\t * The best USABLE estimate, not merely the first.\n\t *\n\t * `usable[0]` is the most trusted admissible basis, and email outranks pageviews — so a campaign\n\t * whose links carry no UTM produced a rate of 0, which fails the checks below, and the panel\n\t * reported \"no traffic-based capture estimate for this range\" while the pageview estimate sat two\n\t * cards above it saying twenty per cent. A partially tagged campaign was worse: it governed the\n\t * correction outright and quadrupled the corrected figure against what the site-wide comparison\n\t * said.\n\t *\n\t * Trust order still decides between estimates that can each carry the correction; it must not\n\t * hand it to one that cannot.\n\t */\n\tconst usable = model.estimates.filter((e) => (\n\t\tadmissible.includes(e.basis) && e.rate > 0 && e.rate < 1 && Number.isFinite(e.rate)\n\t))\n\tif (usable.length === 0) return null\n\tconst rate = usable[0]!.rate\n\tconst rates = usable.map((e) => e.rate)\n\t// The interval is rebuilt from the ADMISSIBLE estimates only.\n\t//\n\t// It fell through to the model's own low/high when filtering left one estimate — and those are\n\t// the min and max across EVERY estimate, including the ones just excluded. So a traffic figure\n\t// was given a lower bound derived entirely from the orders rate: exactly the purchase-tag\n\t// capture this filter exists to keep out, arriving through the back door and printed as the\n\t// bottom of the range. It also bypassed the lone-estimate sampling interval in precisely the\n\t// case the filter creates.\n\tconst bounds = usable.length > 1\n\t\t? { low: Math.min(...rates), high: Math.max(...rates) }\n\t\t: samplingInterval(usable[0]!)\n\tmodel = { ...model, rate, low: bounds.low, high: bounds.high }\n\n\tif (model.rate === null || model.rate <= 0 || model.low === null || model.high === null) return null\n\tif (model.high <= 0) return null\n\n\t// A rate at or above 1 means GA4 is not undercounting on this measure, so there is nothing to\n\t// gross up and pretending otherwise would inflate a figure that is already complete.\n\tif (model.rate >= 1) return null\n\n\t/*\n\t * The interval inverts: the LOW capture rate produces the HIGH estimate of reality.\n\t *\n\t * A low bound of zero means the upper end is unbounded, and that used to discard the whole\n\t * figure — `if (model.low <= 0) return null`. The sampling interval reaches zero whenever the\n\t * rate is at or under roughly 4/(n+4), which at a fifth capture and single-digit orders is the\n\t * archetypal case for these foundries, so the correction vanished exactly where it was wanted.\n\t * Worse, the caller then reported \"not enough overlap between sources to estimate a true\n\t * figure\", which was false: there was overlap and a usable point estimate, and only the upper\n\t * bound was unbounded.\n\t *\n\t * The guard belonged to a different failure — a missing rate — and was catching a legitimately\n\t * wide interval instead. A floored bound keeps the point and states an upper end that is\n\t * honestly enormous rather than pretending there is none.\n\t */\n\tconst floor = Math.max(model.low, 0.01)\n\tconst low = observed / Math.min(1, model.high)\n\tconst high = observed / floor\n\t// Ordered, because the two clamps can cross. `Math.min(1, high)` and `Math.max(low, 0.01)` pass\n\t// each other whenever the capture rate is under about 1% — which is not an exotic case, it is a\n\t// dead tag, the exact failure this package was built for. The panel renders these verbatim, so\n\t// it printed \"~10,000\" over \"1,502 to 500\" with an Estimated badge.\n\treturn {\n\t\tvalue: observed / model.rate,\n\t\tlow: Math.min(low, high),\n\t\thigh: Math.max(low, high),\n\t\t// The estimate this figure was ACTUALLY built from. Callers described it using the model's\n\t\t// own rate and first basis, which after filtering is a different estimate — so the caption\n\t\t// read \"seeing 14% of activity, measured against the orders that exist\" beside a number\n\t\t// derived from the pageview rate of 20%. 1000/0.14 is 7,000, not the 5,000 printed. The\n\t\t// exclusion was fixed in the arithmetic and left broken in the label.\n\t\tbasis: usable[0]!.basis,\n\t\trate,\n\t}\n}\n","/**\n * Event instrumentation history — when an event started, and when it stopped.\n *\n * GA4 cannot backfill events: data from before an event was instrumented does not exist and never\n * will. A range spanning a cutover therefore mixes two measurement regimes, and a chart drawn\n * across it reads as a change in visitor behaviour when it is really a change in what was counted.\n *\n * Outages are the same problem arriving from the other direction, and are easy to miss because the\n * event still exists in the code. Darden is the worked example: its ecommerce events fired normally\n * until a script-loading change on 2025-11-20 stopped them reaching GA4 for nine months. Every one\n * of those days returns a real, honest zero from the API — a zero that means \"not recorded\", not\n * \"no sales\". Without an outage recorded here, a yearly funnel would render that as a collapse in\n * trade rather than as a gap in measurement.\n *\n * This lives in the package rather than in each site repo so all three foundries share one model\n * and one set of semantics, and so the Studio panel and the server handler agree by construction.\n */\n\nimport type { DateRange, MetricValue } from '../types'\nimport { unavailable, partial } from '../types'\n\n/** An event that predates this work — long-running, with no start discontinuity to mark. */\nexport const PREEXISTING = 'preexisting' as const\n\n/**\n * A period during which an event stopped reaching GA4 despite still existing in the code.\n * `until: null` means it is still broken as of now.\n */\nexport interface EventOutage {\n\t/** First ISO date with no data. */\n\tstart: string\n\t/** ISO date it resumed, or null if unresolved. */\n\tuntil: string | null\n\t/** Short human-readable cause, shown in the UI next to the affected figure. */\n\treason?: string\n}\n\n/** An event with a start date and, optionally, periods where it stopped firing. */\nexport interface EventHistory {\n\t/** When it began firing: `PREEXISTING`, or an ISO `YYYY-MM-DD` deploy date. */\n\tfrom: typeof PREEXISTING | string\n\t/** Periods where it stopped. Order does not matter. */\n\toutages?: EventOutage[]\n}\n\n/**\n * When an event fired on a site.\n * `PREEXISTING` = live before this work began. `null` = planned, not yet deployed.\n * An ISO `YYYY-MM-DD` string = the deploy date. An `EventHistory` adds outages.\n */\nexport type EventCutover = typeof PREEXISTING | string | null | EventHistory\n\n/** How completely an event covers a requested range. */\nexport type Coverage =\n\t| { status: 'full' }\n\t| {\n\t\t\tstatus: 'partial'\n\t\t\treason: 'spans_cutover' | 'spans_outage'\n\t\t\t/** Present when the range starts before the event was instrumented. */\n\t\t\tcutover?: string\n\t\t\t/** Outages overlapping the range. Empty for a pure cutover overlap. */\n\t\t\toutages: EventOutage[]\n\t }\n\t| {\n\t\t\tstatus: 'none'\n\t\t\treason: 'not_instrumented' | 'before_cutover' | 'unknown_event' | 'outage'\n\t\t\tcutover?: string\n\t\t\t/** Present when the whole range fell inside an outage. */\n\t\t\toutages?: EventOutage[]\n\t }\n\n/** Normalise the shorthand forms into an EventHistory, or null when not instrumented. */\nfunction toHistory(cutover: EventCutover | undefined): EventHistory | null {\n\tif (cutover === null || cutover === undefined) return null\n\tif (typeof cutover === 'string') return { from: cutover }\n\tif (!cutover.from) return null\n\treturn cutover\n}\n\n/**\n * Whether an outage overlaps a range.\n *\n * The outage's end is EXCLUSIVE — `EventOutage.until` is documented as the date recording resumed,\n * so data exists on that day. This tested `aEnd >= bStart`, which counts the resume date as still\n * missing: an outage ending 1 July against a range starting 1 July was reported as spanning it, and\n * a range with complete data throughout printed \"Undercounted: not recorded …\" plus a notice saying\n * its figures were \"too low — not a real decline\". The `swallowed` check ten lines below already read\n * `until` exclusively, so the two halves of one function disagreed.\n *\n * @param aStart - outage start, inclusive\n * @param aEnd - date recording resumed, exclusive; null means still ongoing\n * @param bStart - range start, inclusive\n * @param bEnd - range end, inclusive\n */\nfunction overlaps(aStart: string, aEnd: string | null, bStart: string, bEnd: string): boolean {\n\t// A null end means \"ongoing\", so it extends past any range end.\n\treturn aStart <= bEnd && (aEnd === null || aEnd > bStart)\n}\n\n/**\n * Determine how well an event covers a date range.\n *\n * @param cutovers - the site's event history map\n * @param eventName - GA4 event name to look up\n * @param range - the requested range\n */\nexport function coverageForRange(\n\tcutovers: Readonly<Record<string, EventCutover>>,\n\teventName: string,\n\trange: Pick<DateRange, 'start' | 'end'>,\n): Coverage {\n\tif (!(eventName in cutovers)) return { status: 'none', reason: 'unknown_event' }\n\n\tconst history = toHistory(cutovers[eventName])\n\tif (!history) return { status: 'none', reason: 'not_instrumented' }\n\n\t// --- start date -----------------------------------------------------------\n\tlet cutover: string | undefined\n\tlet startsBeforeCutover = false\n\n\tif (history.from !== PREEXISTING) {\n\t\tconst cutoverTime = Date.parse(history.from)\n\t\t// An unparseable date is a config error; treat it as unknown rather than silently trusting it.\n\t\tif (Number.isNaN(cutoverTime)) return { status: 'none', reason: 'unknown_event' }\n\n\t\tcutover = history.from\n\t\tif (Date.parse(range.end) < cutoverTime) return { status: 'none', reason: 'before_cutover', cutover }\n\t\tstartsBeforeCutover = Date.parse(range.start) < cutoverTime\n\t}\n\n\t// --- outages --------------------------------------------------------------\n\tconst hit = (history.outages ?? []).filter((o) => overlaps(o.start, o.until, range.start, range.end))\n\n\t// A range sitting entirely inside one outage has no usable data at all.\n\tconst swallowed = hit.find((o) => o.start <= range.start && (o.until === null || o.until > range.end))\n\tif (swallowed) return { status: 'none', reason: 'outage', outages: [swallowed], cutover }\n\n\tif (hit.length > 0) return { status: 'partial', reason: 'spans_outage', outages: hit, cutover }\n\tif (startsBeforeCutover) return { status: 'partial', reason: 'spans_cutover', outages: [], cutover }\n\n\treturn { status: 'full' }\n}\n\n/**\n * Wrap a raw count in a MetricValue that reflects the event's coverage of the range.\n *\n * Call this instead of returning a bare number, so a value that only covers part of the range\n * carries that fact with it all the way to the renderer.\n *\n * @param count - the number GA4 returned, or null if the query could not run\n * @param coverage - result of coverageForRange for the same event and range\n */\nexport function applyCoverage(count: number | null, coverage: Coverage): MetricValue {\n\tif (coverage.status === 'none') {\n\t\tif (coverage.reason === 'outage') {\n\t\t\tconst outage = coverage.outages?.[0]\n\t\t\treturn unavailable('outage', outage ? describeOutage(outage) : undefined)\n\t\t}\n\t\treturn unavailable(\n\t\t\tcoverage.reason === 'before_cutover' ? 'before_cutover' : 'not_instrumented',\n\t\t\tcoverage.cutover ? `Instrumented from ${coverage.cutover}` : undefined,\n\t\t)\n\t}\n\n\tif (count === null) return unavailable('source_error')\n\n\tif (coverage.status === 'partial') {\n\t\tif (coverage.reason === 'spans_outage') {\n\t\t\tconst outage = coverage.outages[0]\n\t\t\t// coveredFrom is the outage end where known, since that is when data resumes.\n\t\t\tconst from = outage?.until ?? outage?.start ?? coverage.cutover ?? ''\n\t\t\treturn partial(count, from, `Undercounted: ${describeOutage(outage)}`)\n\t\t}\n\n\t\tconst from = coverage.cutover ?? ''\n\t\treturn partial(count, from, `Only counted from ${from}, when this event was added`)\n\t}\n\n\treturn { status: 'ok', value: count }\n}\n\n/**\n * Coverage across several events that mean the same thing, taking the best of them.\n *\n * A site mapping five tester events onto one concept has usable data if any one of them has it,\n * so the strongest coverage wins rather than the weakest.\n */\nexport function coverageForAny(\n\tcutovers: Readonly<Record<string, EventCutover>>,\n\teventNames: readonly string[],\n\trange: Pick<DateRange, 'start' | 'end'>,\n): Coverage {\n\tif (eventNames.length === 0) return { status: 'none', reason: 'unknown_event' }\n\n\tconst all = eventNames.map((name) => coverageForRange(cutovers, name, range))\n\treturn (\n\t\tall.find((c) => c.status === 'full') ??\n\t\tall.find((c) => c.status === 'partial') ??\n\t\tall[0] ?? { status: 'none', reason: 'unknown_event' }\n\t)\n}\n\n/** One-line description of an outage, for display beside an affected figure. */\nexport function describeOutage(outage: EventOutage | undefined): string {\n\tif (!outage) return 'an outage affected part of this range'\n\tconst period = outage.until ? `${outage.start} to ${outage.until}` : `${outage.start} onwards`\n\treturn outage.reason ? `not recorded ${period} (${outage.reason})` : `not recorded ${period}`\n}\n\n/**\n * Human-readable notices for every event in a range that is not fully covered.\n * Surfaced in the report envelope so the panel can show caveats without recomputing them.\n */\nexport function coverageNotices(\n\tcutovers: Readonly<Record<string, EventCutover>>,\n\teventNames: readonly string[],\n\trange: Pick<DateRange, 'start' | 'end'>,\n): string[] {\n\tconst notices: string[] = []\n\n\tfor (const name of eventNames) {\n\t\tconst coverage = coverageForRange(cutovers, name, range)\n\n\t\tif (coverage.status === 'partial' && coverage.reason === 'spans_outage') {\n\t\t\tnotices.push(`${name} was ${describeOutage(coverage.outages[0])}, so figures for this range are too low — not a real decline.`)\n\t\t} else if (coverage.status === 'partial') {\n\t\t\tnotices.push(`${name} was instrumented on ${coverage.cutover}; figures before that date are missing, not zero.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'outage') {\n\t\t\tnotices.push(`${name} was ${describeOutage(coverage.outages?.[0])}, covering this whole range. The figure is unavailable, not zero.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'not_instrumented') {\n\t\t\tnotices.push(`${name} is not instrumented on this site, so it cannot be reported for any range.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'before_cutover') {\n\t\t\tnotices.push(`${name} did not exist during this range; it was instrumented on ${coverage.cutover}.`)\n\t\t}\n\t}\n\n\treturn notices\n}\n","/**\n * Per-site adapter configuration.\n *\n * The three foundries differ in ways that cannot be abstracted away: different GA4 event coverage,\n * different order schemas, different flows that exist on one site and not another. Rather than\n * branching on a site id inside the reports — which would mean editing this package to onboard a\n * fourth foundry — each site describes itself through this config and the reports stay generic.\n */\n\nimport type { EventCutover } from './cutover'\n\n/** GA4 connection for one site. */\nexport interface Ga4Config {\n\t/** Numeric GA4 property id. NOT the `G-XXXXXXX` measurement id, which is the client-side one. */\n\tpropertyId: string\n\t/** IANA timezone the property is configured with. Every range is anchored to this. */\n\ttimezone: string\n\t/**\n\t * Hostnames belonging to this site. When set, every GA4 report is restricted to them.\n\t *\n\t * This exists because a GA4 property is not necessarily one website. Darden's property also\n\t * receives `impactsport.ca`, an unrelated business, which contributed 68 of 389 sessions in a\n\t * sample week — inside the headline session count, inside both percentages computed from it,\n\t * and inside the funnel's entry rung. Without this, the tool silently reports two businesses\n\t * added together and calls the total one site.\n\t *\n\t * Staging and legacy hosts belong here too, or are deliberately left out to exclude them.\n\t * Omit entirely to accept every hostname, which is the old behaviour.\n\t */\n\thostnames?: readonly string[]\n}\n\n/** Vercel Web Analytics connection for one site. */\nexport interface VercelConfig {\n\tprojectId: string\n\tteamId?: string\n}\n\n/** Where to count orders, and what they are called on this site. */\nexport interface OrdersConfig {\n\t/** Document type holding orders. */\n\tdocumentType: string\n\t/**\n\t * Field holding the typeface references on an order, if the site has one.\n\t * Null where orders do not resolve to typefaces in a usable way.\n\t */\n\ttypefacesField: string | null\n\t/**\n\t * GROQ filter appended to exclude non-typeface orders, e.g. merch-only orders on Darden.\n\t * Merch inflates a family's apparent purchase count if not excluded.\n\t */\n\texcludeFilter?: string\n\t/**\n\t * Field holding the order's status. Defaults to `orderStatus`.\n\t *\n\t * The status is always read and always reported as a breakdown, even when nothing is filtered\n\t * on it — an operator cannot configure `countedStatuses` without first seeing what vocabulary\n\t * their own orders actually use.\n\t */\n\tstatusField?: string\n\t/**\n\t * Statuses that count as a real sale. Orders with any other status are excluded from every\n\t * figure and reported separately as an excluded count.\n\t *\n\t * Omit to count every order regardless of status, which is the old behaviour and means test,\n\t * failed, pending and refunded orders are all counted as sales. At seven orders a quarter one\n\t * test order is a 14% error, so leaving this unset is rarely right once the vocabulary is known.\n\t */\n\tcountedStatuses?: readonly string[]\n\t/**\n\t * Field holding the order's total, e.g. `total` or `amountPaid`. A number, in major units.\n\t *\n\t * Revenue was originally excluded from this package on the grounds that it reports behaviour\n\t * and the sales portal reports sales. That was the wrong line: an order total is not a customer\n\t * field, and without it a $30 web licence and a $400 multi-seat desktop licence are the same\n\t * integer — so nothing in the tool can be ranked by what it is worth. The PII rule is unchanged\n\t * and still enforced by the projection allow-list; only the value crosses over.\n\t */\n\ttotalField?: string\n\t/**\n\t * True when `totalField` holds minor units (cents) rather than major units (dollars).\n\t *\n\t * Darden's `amountCharged` is the Stripe capture in integer minor units, deliberately, because\n\t * it is the one figure that reconciles against Stripe. Reading it as dollars would report every\n\t * revenue figure a hundred times too large — a number wrong enough to be obvious once, and\n\t * quietly wrong forever in a ratio.\n\t */\n\ttotalInMinorUnits?: boolean\n\t/** ISO 4217 code for `totalField`, e.g. `USD`. Used for formatting only. */\n\tcurrency?: string\n\t/**\n\t * Licence tier fields on each typeface entry of an order, mapped to the licence type they mean.\n\t *\n\t * Darden: `{ licenseDesktop: 'Desktop', licenseWeb: 'Web', licenseApp: 'App', licenseFluid: 'Fluid' }`.\n\t * Each names an object of `{ value, label, yearsValue, yearsLabel }`, so an order already records\n\t * both the tier bought and the term it was bought for — the two variables in the pricing\n\t * question — while the tool reported a purchase as one undifferentiated event.\n\t *\n\t * Omit on a site whose orders do not carry tiers; the licence report then reports as\n\t * unavailable rather than empty.\n\t */\n\tlicenceFields?: Record<string, string>\n}\n\n/**\n * Which of this site's events carry a given meaning.\n *\n * Sites do not converge on one name. TDF already emits five distinct type-tester events\n * (`variable_font_change`, `variable_style_change`, `style_change`, `feature_change`,\n * `opentype_feature`) and has done for a long time; inventing a `tester_engaged` for it would\n * discard that history and measure the same thing twice. Each entry is a list, and the counts are\n * summed, so a site can map several of its own events onto one concept.\n */\nexport interface EventNameMap {\n\t/** Events that count as genuine type-tester engagement — a change from the default state. */\n\ttester?: string[]\n\t/** Events fired when a visitor grants analytics consent. */\n\tconsent?: string[]\n\t/**\n\t * Events fired when a visitor submits a custom-typeface or licensing enquiry.\n\t *\n\t * A commission is worth many multiples of a licence, so an enquiry is the most valuable action\n\t * on these sites. Darden has fired `enquiry_submit` throughout and no report read it, which\n\t * meant the tool gave the $30 path six funnel rungs and the four-figure path none — and scored\n\t * the enquiry-led visitor as a drop-off.\n\t */\n\tenquiry?: string[]\n\t/** Events fired when a visitor joins the mailing list — the only audience a foundry owns. */\n\tsubscribe?: string[]\n\t/** Events fired when a visitor downloads a trial font or a specimen PDF. */\n\tassetDownload?: string[]\n}\n\n/** One site's complete description of itself. */\nexport interface SiteAnalyticsConfig {\n\t/** Stable machine id, e.g. `darden`. */\n\tsiteId: string\n\t/** Human label shown in the Studio UI. */\n\tlabel: string\n\t/** Null when GA4 is not wired up for this site — reports degrade rather than fail. */\n\tga4: Ga4Config | null\n\t/** Null when Vercel Web Analytics is unavailable, e.g. on a plan tier without API access. */\n\tvercel: VercelConfig | null\n\torders: OrdersConfig\n\t/**\n\t * Whether this site has a Mailchimp audience worth reporting on.\n\t *\n\t * A flag, not credentials: the key and list id come from the environment like every other\n\t * secret. TDF has no Mailchimp at all, so it omits this and the source reports as unconfigured\n\t * rather than erroring on every request.\n\t */\n\tmailchimp?: { enabled: true }\n\t/**\n\t * When each GA4 event began firing here. Events absent from this map report as `unknown_event`,\n\t * which is the honest answer for a flow the site does not have — distinct from `not_instrumented`,\n\t * which implies it is merely pending.\n\t */\n\teventCutovers: Record<string, EventCutover>\n\t/**\n\t * This site's own names for the events the reports look for. Omit to use the defaults.\n\t * Names listed here must also appear in `eventCutovers`, or they report as unknown events.\n\t */\n\teventNames?: EventNameMap\n\t/**\n\t * Known measurement quirks for this site, shown with every report.\n\t *\n\t * Some differences between sites are semantic and cannot be normalised away — MCKL and TDF fire\n\t * `add_to_cart` on every selection change rather than on a discrete cart action, so their cart\n\t * step counts a different act from a site that fires it once. Stating that beside the figure is\n\t * the only honest option; silently comparing them is not.\n\t */\n\tcaveats?: string[]\n\t/**\n\t * Extra referrer hosts that count as design-industry coverage for this site.\n\t *\n\t * The package ships a short default list, which is necessarily generic and goes stale. A\n\t * foundry's own press — a particular newsletter, a studio blog, a regional design publication —\n\t * cannot be in it, and the resulting figure was being read as a verdict on the design press\n\t * rather than as the coverage of a hard-coded list. Entries here are added to the defaults.\n\t */\n\tdesignIndustrySources?: readonly string[]\n\n\t/**\n\t * Studio roles allowed to read these reports, enforced server-side.\n\t *\n\t * Must match the `roles` given to the plugin, which only controls whether the tab is shown.\n\t * Omit to allow any authenticated Studio user of this project. These panels expose order-derived\n\t * conversion figures, so gating to administrators is usually right — and unlike the plugin\n\t * option, setting it here actually prevents a direct call to the API route.\n\t */\n\troles?: readonly string[]\n\t/** Origins allowed to call this site's handler cross-origin, e.g. a separately deployed Studio. */\n\tallowedStudioOrigins?: string[]\n}\n\n/** A problem found while validating a site config. */\nexport interface ConfigProblem {\n\tfield: string\n\tmessage: string\n}\n\n/**\n * Validate a site config at runtime.\n *\n * Compile-time types do not survive into a consuming site's `sanity.config.js`, which is plain\n * JavaScript — so a typo'd property id or a measurement id pasted where a numeric property id\n * belongs would otherwise surface as an empty chart rather than an error.\n *\n * @param config - the candidate config\n * @returns the problems found; an empty array means the config is usable\n */\nexport function validateSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): ConfigProblem[] {\n\tconst problems: ConfigProblem[] = []\n\n\tif (!config) {\n\t\treturn [{ field: 'config', message: 'No site config supplied' }]\n\t}\n\n\tif (!config.siteId) problems.push({ field: 'siteId', message: 'Required' })\n\tif (!config.label) problems.push({ field: 'label', message: 'Required' })\n\n\tif (config.ga4) {\n\t\tconst { propertyId, timezone } = config.ga4\n\t\tif (!propertyId) {\n\t\t\tproblems.push({ field: 'ga4.propertyId', message: 'Required when ga4 is configured' })\n\t\t} else if (propertyId.startsWith('G-')) {\n\t\t\tproblems.push({\n\t\t\t\tfield: 'ga4.propertyId',\n\t\t\t\tmessage: `Looks like a measurement id (\"${propertyId}\"). The Data API needs the numeric property id from GA4 Admin instead.`,\n\t\t\t})\n\t\t} else if (!/^\\d+$/.test(propertyId)) {\n\t\t\tproblems.push({ field: 'ga4.propertyId', message: 'Must be the numeric GA4 property id' })\n\t\t}\n\n\t\tif (!timezone) {\n\t\t\tproblems.push({ field: 'ga4.timezone', message: 'Required — ranges are anchored to it' })\n\t\t} else if (!isValidTimeZone(timezone)) {\n\t\t\tproblems.push({ field: 'ga4.timezone', message: `Not a recognised IANA timezone: ${timezone}` })\n\t\t}\n\t}\n\n\t// The GROQ field slots. Not a sandbox — the config author is the site's own code author — but\n\t// the realistic mistake is a typo or a paste that points one of these at a customer field, and\n\t// a leak through them would be silent: PII read as a status becomes \"(no status)\" and PII read\n\t// as a total becomes null, so it never surfaces in a panel to be noticed.\n\tconst FIELD_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*$/\n\tconst PII_HINTS = ['email', 'name', 'phone', 'address', 'postcode', 'postal', 'zip', 'customer', 'billing']\n\n\tconst fieldSlots: Array<[string, string | null | undefined]> = [\n\t\t['orders.statusField', config.orders?.statusField],\n\t\t['orders.totalField', config.orders?.totalField],\n\t\t['orders.typefacesField', config.orders?.typefacesField],\n\t]\n\n\tfor (const name of Object.keys(config.orders?.licenceFields ?? {})) {\n\t\tfieldSlots.push([`orders.licenceFields.${name}`, name])\n\t}\n\n\tfor (const [field, value] of fieldSlots) {\n\t\tif (!value) continue\n\t\tif (!FIELD_PATH.test(value)) {\n\t\t\tproblems.push({ field, message: `Must be a plain field path, e.g. \"orderStatus.status\": ${value}` })\n\t\t\tcontinue\n\t\t}\n\t\tconst lowered = value.toLowerCase()\n\t\tconst hint = PII_HINTS.find((word) => lowered.includes(word))\n\t\tif (hint) {\n\t\t\tproblems.push({\n\t\t\t\tfield,\n\t\t\t\tmessage: `Looks like it may point at personal data (\"${hint}\"). No query in this package may project a customer field.`,\n\t\t\t})\n\t\t}\n\t}\n\n\t// A revenue figure with no currency renders as a bare integer beside columns that are counts,\n\t// which is indistinguishable from one.\n\tif (config.orders?.totalField && !config.orders.currency) {\n\t\tproblems.push({\n\t\t\tfield: 'orders.currency',\n\t\t\tmessage: 'Required when totalField is set, or revenue renders as an unlabelled number beside counts',\n\t\t})\n\t}\n\n\t// An event named here but absent from eventCutovers reports as an unknown event and silently\n\t// contributes nothing — a cross-field constraint that is cheap to check and easy to miss.\n\tfor (const [concept, names] of Object.entries(config.eventNames ?? {})) {\n\t\tfor (const name of names ?? []) {\n\t\t\tif (config.eventCutovers && !(name in config.eventCutovers)) {\n\t\t\t\tproblems.push({\n\t\t\t\t\tfield: `eventNames.${concept}`,\n\t\t\t\t\tmessage: `\"${name}\" is not in eventCutovers, so it will report as an unknown event`,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\tfor (const host of config.ga4?.hostnames ?? []) {\n\t\t// A scheme or a path here would silently match nothing: GA4's hostName dimension is the\n\t\t// bare host. Catching it at construction beats an empty dashboard.\n\t\tif (/^https?:\\/\\//.test(host) || host.includes('/')) {\n\t\t\tproblems.push({ field: 'ga4.hostnames', message: `Must be a bare hostname, not a URL: ${host}` })\n\t\t}\n\t}\n\n\tif (config.orders?.countedStatuses && config.orders.countedStatuses.length === 0) {\n\t\tproblems.push({\n\t\t\tfield: 'orders.countedStatuses',\n\t\t\tmessage: 'Empty array would exclude every order. Omit the field to count all statuses.',\n\t\t})\n\t}\n\n\tif (config.vercel && !config.vercel.projectId) {\n\t\tproblems.push({ field: 'vercel.projectId', message: 'Required when vercel is configured' })\n\t}\n\n\tif (!config.orders?.documentType) {\n\t\tproblems.push({ field: 'orders.documentType', message: 'Required' })\n\t}\n\n\tif (!config.eventCutovers) {\n\t\tproblems.push({ field: 'eventCutovers', message: 'Required — omit events the site does not have' })\n\t}\n\n\tfor (const origin of config.allowedStudioOrigins ?? []) {\n\t\tif (!/^https?:\\/\\//.test(origin)) {\n\t\t\tproblems.push({ field: 'allowedStudioOrigins', message: `Must be a full origin including scheme: ${origin}` })\n\t\t}\n\t\tif (origin.endsWith('/')) {\n\t\t\tproblems.push({ field: 'allowedStudioOrigins', message: `Must not have a trailing slash: ${origin}` })\n\t\t}\n\t}\n\n\treturn problems\n}\n\n/** Whether a string is an IANA timezone this runtime recognises. */\nexport function isValidTimeZone(timeZone: string): boolean {\n\ttry {\n\t\tnew Intl.DateTimeFormat('en-CA', { timeZone })\n\t\treturn true\n\t} catch {\n\t\treturn false\n\t}\n}\n\n/** Throw if a config is unusable, naming every problem at once rather than one per run. */\nexport function assertValidSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): asserts config is SiteAnalyticsConfig {\n\tconst problems = validateSiteConfig(config)\n\tif (problems.length > 0) {\n\t\tconst detail = problems.map((p) => ` ${p.field}: ${p.message}`).join('\\n')\n\t\tthrow new Error(`Invalid visitor-insights site config:\\n${detail}`)\n\t}\n}\n"],"mappings":"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","names":["import_sanity","import_react","import_sanity_ui_compat","import_react","React","unavailable","rows","import_sanity_ui_compat","import_sanity_ui_compat","import_react","import_sanity_ui_compat","import_jsx_runtime","React","d3Max","d3Line","import_jsx_runtime","import_jsx_runtime","React","React","React"]}
|