From a50fefc658620eb0843981045de94571bbac7883 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 21 Aug 2026 21:04:39 +0200 Subject: [PATCH] fix(analytics): size the web analytics page by container, not viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analytics page picked its layouts off viewport breakpoints (lg:, max-sm:) while the content column can be ~512px narrower than the viewport with both sidebars open, so the KPI strip and breakdown grid went 4-up/2-up inside spaces that fit half that. Switch both to the @container/page convention (880px threshold), give each breakdown card its own @container/panel so the in-card table sheds its Share/secondary column below 380px — the shedding classes existed but were gated on `ranked`, which is only true in the expand dialog — and let the card filter input flex beside the tabs instead of always wrapping. Tiles tighten padding and step the value to 22px under 560px of container via new optional className/valueClassName props on StatRailItem, and the chart's fixed 52px axis gutter drops to 36px on phones. --- .../analytics/analytics-breakdown-panel.tsx | 21 ++++++++++++++----- .../analytics/analytics-metric-strip.tsx | 17 ++++++++++----- .../analytics/analytics-traffic-chart.tsx | 10 +++++++-- .../components/infra/primitives/stat-rail.tsx | 9 +++++++- apps/web/src/routes/analytics/index.tsx | 4 ++-- 5 files changed, 46 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/analytics/analytics-breakdown-panel.tsx b/apps/web/src/components/analytics/analytics-breakdown-panel.tsx index 11d89741a..ae6c5617e 100644 --- a/apps/web/src/components/analytics/analytics-breakdown-panel.tsx +++ b/apps/web/src/components/analytics/analytics-breakdown-panel.tsx @@ -209,14 +209,19 @@ export function AnalyticsBreakdownPanel({ ) return ( -
+ // Its own container, not `/page`: the panel's width depends on both the page + // column and whether the breakdown grid is 1- or 2-up, so the panel itself + // is the only honest measure. The expand dialog portals out of this card — + // `/panel` classes are card-only (`!ranked`); the dialog keeps viewport + // queries, which are truthful there via its `max-w-[92vw]`. +
{tabs}
@@ -432,13 +437,18 @@ function BreakdownTable({ currentKey={sortKey} dir={sortDir} onSort={onSort} + // In the card, Views is the ranking column for pages — Sessions is + // the number a very narrow panel can afford to drop. + hidden={!ranked && hasViews ? "hidden @min-[380px]/panel:flex" : undefined} /> {showShare ? ( label="Share" width="w-16" align="right" - hidden={ranked ? "max-sm:hidden" : undefined} + // Card mode sheds Share by panel width — the row's background bar + // already carries it. The dialog sheds by viewport, as before. + hidden={ranked ? "max-sm:hidden" : "hidden @min-[380px]/panel:flex"} /> ) : null} @@ -462,7 +472,7 @@ function BreakdownTable({ // selected row's background-color instead of overwriting it. style={shareBar(row.share)} className={cn( - "group flex w-full items-center gap-4 border-b border-border/40 px-4 py-2 text-left transition-colors last:border-0", + "group flex w-full items-center gap-3 border-b border-border/40 px-3 py-2 text-left transition-colors last:border-0 @min-[420px]/panel:gap-4 @min-[420px]/panel:px-4", // Hover and focus lift the whole row from behind the fill, so a // long bar and a short one light up by the same amount. // `--foreground`, not `--muted`: muted is barely above this @@ -513,6 +523,7 @@ function BreakdownTable({ "text-right font-mono text-[11px] tabular-nums", ranked ? "w-16 sm:w-24" : hasViews ? "w-20" : "w-24", hasViews && !ranked && "text-muted-foreground", + !ranked && hasViews && "hidden @min-[380px]/panel:inline-block", )} > {formatNumber(row.count)} @@ -521,7 +532,7 @@ function BreakdownTable({ {formatPercent(row.share)} diff --git a/apps/web/src/components/analytics/analytics-metric-strip.tsx b/apps/web/src/components/analytics/analytics-metric-strip.tsx index 5d57c6548..810b14622 100644 --- a/apps/web/src/components/analytics/analytics-metric-strip.tsx +++ b/apps/web/src/components/analytics/analytics-metric-strip.tsx @@ -18,12 +18,17 @@ import { * this grid needs the rule between its two rows at every width. The classes are * otherwise `StatRail`'s exactly. * - * The 4-up break is held to `lg` rather than `md`: eight tiles carrying a value, - * a delta and a sparkline truncate their labels at tablet width where four do - * not. + * The 4-up break is a *container* query, not a viewport one: with both sidebars + * open the content column can be ~512px narrower than the viewport, and a + * viewport `lg:` flipped to four columns inside a space that fits two. 880px is + * the honest budget — each tile carries `px-5`, a no-wrap value ("12m 45s" at + * 26px mono is ~110px), a `w-24` spark slot and the gaps, ~220px apiece. */ const GRID = - "grid grid-cols-2 divide-x divide-y divide-border overflow-hidden rounded-md border bg-card lg:grid-cols-4" + "grid grid-cols-2 divide-x divide-y divide-border overflow-hidden rounded-md border bg-card @min-[880px]/page:grid-cols-4" + +/** Tighter tile chrome once the 2-col grid leaves each tile under ~260px. */ +const TILE_NARROW = "@max-[560px]/page:px-4 @max-[560px]/page:py-3" interface AnalyticsMetricStripProps { source: AnalyticsMetricSource @@ -104,6 +109,8 @@ function MetricTile({ disabled={!available} onSelect={() => onSelect(metric.key)} delay={delay} + className={TILE_NARROW} + valueClassName="@max-[560px]/page:text-[22px]" /> ) } @@ -146,7 +153,7 @@ export function AnalyticsMetricStripLoading() { return (
{ANALYTICS_METRICS.map((metric) => ( -
+
diff --git a/apps/web/src/components/analytics/analytics-traffic-chart.tsx b/apps/web/src/components/analytics/analytics-traffic-chart.tsx index 7567426ea..dd2534c1a 100644 --- a/apps/web/src/components/analytics/analytics-traffic-chart.tsx +++ b/apps/web/src/components/analytics/analytics-traffic-chart.tsx @@ -18,6 +18,7 @@ import { verticalGradient, type PlotTooltipSeries, } from "@maple/ui/components/plot" +import { useMediaQuery } from "@maple/ui/hooks/use-media-query" import { useTheme } from "@maple/ui/hooks/use-theme" import { linkedCursorChartProps } from "@/hooks/use-linked-cursor" @@ -153,6 +154,11 @@ export function AnalyticsTrafficChart({ metric, companion, source, syncId }: Ana [metric, companion, colors, totals], ) + // On a phone the 52px axis gutter is ~15% of the plot; the compact tick labels + // ("1.2k", "45s") fit in 36. Viewport rather than container is honest here — + // on phones the sidebars are sheets, so the two agree. + const narrow = useMediaQuery("max-sm") + const definition = useMemo(() => { const at = (point: TrafficPoint) => point.label // A bucket one table has and the other doesn't is a gap, not a zero — @@ -217,12 +223,12 @@ export function AnalyticsTrafficChart({ metric, companion, source, syncId }: Ana }, }, }, - margin: { left: 52, right: 8, top: 4, bottom: 0 }, + margin: { left: narrow ? 36 : 52, right: 8, top: 4, bottom: 0 }, focus: "group-x", focusRing: false, tooltip: cursorTooltip(focusStore.anchor), }) - }, [data, painted, gradientPrefix, chromeColors, metric, focusStore]) + }, [data, painted, gradientPrefix, chromeColors, metric, focusStore, narrow]) // Only when there are two series to tell apart — a lone series is already // named by the card title, and a legend restating it is one accessory too many. diff --git a/apps/web/src/components/infra/primitives/stat-rail.tsx b/apps/web/src/components/infra/primitives/stat-rail.tsx index 6ea2a48ad..34d45bfd2 100644 --- a/apps/web/src/components/infra/primitives/stat-rail.tsx +++ b/apps/web/src/components/infra/primitives/stat-rail.tsx @@ -51,6 +51,10 @@ interface StatRailItemProps { selected?: boolean /** A tile with nothing to show: still rendered, but not selectable. */ disabled?: boolean + /** Extra classes on the tile shell, e.g. container-query padding overrides. */ + className?: string + /** Extra classes on the value, e.g. a container-query size step-down. */ + valueClassName?: string } /** @@ -78,6 +82,8 @@ export function StatRailItem({ onSelect, selected, disabled, + className, + valueClassName, }: StatRailItemProps) { const body = ( <> @@ -105,6 +111,7 @@ export function StatRailItem({ className={cn( "shrink-0 whitespace-nowrap font-mono text-[26px] font-semibold tabular-nums leading-none tracking-[-0.01em]", VALUE_TONE[tone], + valueClassName, )} > {value} @@ -125,7 +132,7 @@ export function StatRailItem({ ) - const shell = cn("relative px-5 py-4 animate-in fade-in slide-in-from-bottom-1 duration-500") + const shell = cn("relative px-5 py-4 animate-in fade-in slide-in-from-bottom-1 duration-500", className) const style = delay ? { animationDelay: `${delay}ms`, animationFillMode: "backwards" } : undefined if (!onSelect) { diff --git a/apps/web/src/routes/analytics/index.tsx b/apps/web/src/routes/analytics/index.tsx index 504aa6b80..3ee71c81b 100644 --- a/apps/web/src/routes/analytics/index.tsx +++ b/apps/web/src/routes/analytics/index.tsx @@ -367,7 +367,7 @@ function AnalyticsContent({ {Result.builder(breakdownsResult) .onInitial(() => ( -
+
@@ -522,7 +522,7 @@ function AnalyticsContent({ ] return ( -
+
{cards.map((card) => (