Even out the Insights overview inter-section spacing

The overview is a vertical stack on a single space-y-6 rhythm, but two
seams read wrong: Trends to period-in-review felt too wide while the
cycle summary to signals-of-the-day seam had no gap at all. The missing
gap traced to the cycle-summary card wrapping itself in a display:contents
box, which collapsed its margin so space-y skipped it.

- Give the cycle summary a real section wrapper (heading above the card,
  matching every other section) so the inter-section margin applies.
- Document space-y-6 as the single gap, top to bottom; every section is
  a real section or renders null, never a contents wrapper that strands
  a phantom margin.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 14:15:22 +02:00
1 parent 86f416fb2a
commit 6abc178c88
3 files changed
+74 -52

No files matched your search

+9 -6
View File
@@ -366,6 +366,13 @@ export default function InsightsPage() {
// wrap used `space-y-6` (24 px), so the overview read as two tiers. Unify
// to `space-y-6` — it matches the vitals wrap and tightens the overview in
// line with the "Insights gives away too much space" direction.
//
// v1.15.10 — `space-y-6` is now the SINGLE inter-section gap, top to
// bottom. Every section renders as a real `<section>` (or `null` — never a
// `display: contents` wrapper, which used to collapse the cycle-summary box
// so `space-y` skipped its margin and the cycle→signals seam read as
// zero-gap). Each section owns its OWN `SectionHeading` + `space-y-3` to its
// card, so the rhythm is even regardless of which sections are present.
<div className="space-y-6">
<HeroStrip
briefing={briefingPayload}
@@ -377,9 +384,7 @@ export default function InsightsPage() {
: undefined
}
onPickPrompt={
coachLaunch
? (prompt) => coachLaunch.askCoach(prompt)
: undefined
coachLaunch ? (prompt) => coachLaunch.askCoach(prompt) : undefined
}
healthScore={analytics?.healthScore ?? undefined}
/>
@@ -392,9 +397,7 @@ export default function InsightsPage() {
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
// tile, only for a cycle-tracking account, so its calendar read never
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
extraTile={
user?.cycleTrackingEnabled ? <CycleRingTile /> : undefined
}
extraTile={user?.cycleTrackingEnabled ? <CycleRingTile /> : undefined}
// v1.15.5 — when the cycle ring is shown it TAKES the Strain slot:
// hide Strain so the strip stays compact instead of growing a sixth
// tile. Strain stays visible for non-cycle accounts.
@@ -57,6 +57,8 @@ vi.mock("../use-cycle", async () => {
...actual,
useCycleCalendar: () => calendarState.current,
useCycleInsights: () => insightsState.current,
// The card now reads the profile lengths for the low-data idealized ring.
useCycleProfile: () => ({ data: undefined }),
};
});
@@ -173,7 +175,11 @@ describe("<CycleInsightSummaryCard>", () => {
});
it("renders nothing while the calendar read is still resolving", () => {
calendarState.current = { data: undefined, isLoading: true, isError: false };
calendarState.current = {
data: undefined,
isLoading: true,
isError: false,
};
const html = render(<CycleInsightSummaryCard />);
expect(html).toBe("");
});
@@ -2,13 +2,19 @@
import { useMemo } from "react";
import Link from "next/link";
import { ArrowRight, Sparkles } from "lucide-react";
import { ArrowRight, Droplets } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
import { PHASE_HUE } from "./phase-tokens";
import { deriveWheelState } from "./wheel-state";
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
import {
localYmd,
useCycleCalendar,
useCycleInsights,
useCycleProfile,
} from "./use-cycle";
/**
* v1.15.2 — the gated cycle-insights summary card for the main Insights page.
@@ -54,10 +60,18 @@ export function CycleInsightSummaryCard() {
const calendar = useCycleCalendar(from, to);
const insights = useCycleInsights();
// Shared 5-min-cached profile read so a low-data tracker still gets the
// canonical four-phase ring (no extra request — shared cache key).
const profile = useCycleProfile();
const wheel = useMemo(
() => deriveWheelState(calendar.data?.days ?? [], today),
[calendar.data, today],
() =>
deriveWheelState(calendar.data?.days ?? [], today, {
typicalCycleLength: profile.data?.typicalCycleLength,
typicalPeriodLength: profile.data?.typicalPeriodLength,
lutealPhaseLength: profile.data?.lutealPhaseLength,
}),
[calendar.data, today, profile.data],
);
// Stay silent until the calendar read resolves, and on a hard error: the
@@ -83,30 +97,29 @@ export function CycleInsightSummaryCard() {
// node never matched, so the tile stayed stuck at the rise rule's
// `opacity: 0` — invisible but still occupying its box, which read as an
// oversized gap on the overview. The wrapper restores the entrance.
<div data-revealed="true" className="contents">
<section
data-slot="cycle-insight-summary"
data-phase={phase ?? "none"}
aria-label={t("cycle.insightsSummary.title")}
style={{ "--tile-hue": hue } as React.CSSProperties}
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
>
<div className="flex items-start justify-between gap-3">
{/* Zone 1 — eyebrow + the current phase / cycle-day read. */}
<section
data-slot="cycle-insight-summary-section"
aria-label={t("cycle.insightsSummary.title")}
className="space-y-3"
>
<SectionHeading
icon={Droplets}
title={t("cycle.insightsSummary.title")}
/>
{/* `data-revealed` must sit on an ANCESTOR of the `.wellness-tile-rise`
element — the keyframe selector is `[data-revealed="true"]
.wellness-tile-rise` (descendant combinator). */}
<div data-revealed="true" className="contents">
<div
data-slot="cycle-insight-summary"
data-phase={phase ?? "none"}
style={{ "--tile-hue": hue } as React.CSSProperties}
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
>
{/* Zone 1 — the current phase / cycle-day read. */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles
className="h-4 w-4 shrink-0"
style={{ color: hue }}
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("cycle.insightsSummary.title")}
</span>
</div>
<h3
className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold"
className="text-foreground flex items-center gap-2 text-base font-semibold"
aria-label={ariaLabel}
>
<span
@@ -126,26 +139,26 @@ export function CycleInsightSummaryCard() {
: t("cycle.insightsSummary.noActiveCycle")}
</p>
</div>
</div>
{/* Zone 2 — the one headline phase finding (shared component owns the
FDR-gated copy AND the honest "not enough cycles yet" empty line, so
this can never show a broken state). */}
<div className="mt-3">
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
</div>
{/* Zone 2 — the one headline phase finding (shared component owns the
FDR-gated copy AND the honest "not enough cycles yet" empty line, so
this can never show a broken state). */}
<div className="mt-3">
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
</div>
{/* Zone 3 — the deep-link into the single source of truth: the cycle
insights tab. The cycle page reads `?tab=insights` and opens that tab. */}
<Link
href="/cycle?tab=insights"
data-slot="cycle-insight-summary-link"
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
>
{t("cycle.insightsSummary.viewDetails")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</section>
</div>
{/* Zone 3 — the deep-link into the single source of truth: the cycle
insights tab. The cycle page reads `?tab=insights` and opens it. */}
<Link
href="/cycle?tab=insights"
data-slot="cycle-insight-summary-link"
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
>
{t("cycle.insightsSummary.viewDetails")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
</div>
</section>
);
}