mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(insights): extract tab strip + relocate regenerate button to top-right
Marc directive 2026-05-14: the page-level regenerate affordance has to
stay reachable while the user is reading the per-metric cards. Up to
v1.4.24 it lived inside the hero band's action row, which scrolled
away as soon as the user moved past the briefing.
What changed:
- New `src/components/insights/insights-tab-strip.tsx` carries the
six metric pills + the regenerate icon button. The strip is
`sticky top-0` with `bg-background/95 backdrop-blur` so it lives
above every section as the user scrolls.
- Right slot: a 44×44 icon-only `<RefreshCw>` button with
`aria-label={t("insights.regenerateAnalysis")}` and a sonner
success toast keyed to the falling edge of `regenerating`
(rising-edge guard via a ref so the toast only fires when an
in-flight regen finishes, not on every render).
- The hero strip's `onRegenerate` / `regenerating` props are gone;
its action row is now Weekly-report + Ask-the-Coach only. The
JSDoc comment block points to the new tab strip for the
regenerate affordance.
- `src/app/insights/page.tsx` mounts `<InsightsTabStrip
onRegenerate={advisor.regenerate} regenerating={...} />` and
drops the inline `InsightsSectionNav` function (its logic moved
into the new component verbatim).
- Two new translation keys added under the `insights.*` namespace:
`regenerateAnalysis` ("Analyse neu starten" / "Re-run analysis")
drives the aria-label + title; `regenerateSuccess` ("Analyse
wurde neu erstellt" / "Analysis refreshed") drives the toast.
The existing hero-strip test that pinned the regenerate slot was
flipped — the slot must NOT render under any prop combination. The
insights-polish test that asserted `<HeroStrip onRegenerate={advisor.regenerate}>`
was rewritten to assert the wiring is on `<InsightsTabStrip>` and that
the `<HeroStrip>` JSX block carries no `onRegenerate` prop.
This commit is contained in:
1 parent
3c608520f6
commit
72e64c816d
7 files changed
+259
-195
No files matched your search
@@ -761,6 +761,8 @@
|
||||
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
|
||||
"heroRegenerate": "Neu generieren",
|
||||
"heroRegenerating": "Wird generiert",
|
||||
"regenerateAnalysis": "Analyse neu starten",
|
||||
"regenerateSuccess": "Analyse wurde neu erstellt",
|
||||
"heroFallbackSubtitle": "Täglicher Blick auf deine Trends — direkt aus den Zahlen, die du protokollierst.",
|
||||
"heroGreetingMorning": "Guten Morgen",
|
||||
"heroGreetingAfternoon": "Guten Tag",
|
||||
|
||||
@@ -761,6 +761,8 @@
|
||||
"heroPersonalBaseline": "Based on your last 90 days",
|
||||
"heroRegenerate": "Regenerate",
|
||||
"heroRegenerating": "Regenerating",
|
||||
"regenerateAnalysis": "Re-run analysis",
|
||||
"regenerateSuccess": "Analysis refreshed",
|
||||
"heroFallbackSubtitle": "A daily read of your trends, drawn straight from the numbers you've logged.",
|
||||
"heroGreetingMorning": "Good morning",
|
||||
"heroGreetingAfternoon": "Good afternoon",
|
||||
|
||||
@@ -107,13 +107,20 @@ describe("v1.4.19 A3 — /insights polish", () => {
|
||||
expect(advisorBlock?.[0]).not.toContain("onRegenerate");
|
||||
});
|
||||
|
||||
it("hero strip is the always-visible page-level refresh affordance", () => {
|
||||
it("tab strip owns the always-visible page-level refresh affordance (v1.4.25 W3)", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The hero owns the page-level regenerate button (mounted via
|
||||
// `onRegenerate={advisor.regenerate}` on `<HeroStrip>`).
|
||||
// v1.4.25 W3 — the regenerate handler moved from `<HeroStrip>` to
|
||||
// `<InsightsTabStrip>` (icon-only RefreshCw on the sticky tab
|
||||
// strip). The hero strip no longer takes an `onRegenerate` prop.
|
||||
// We isolate the `<HeroStrip ... />` block (lazy match ending at
|
||||
// the first `/>`) and assert it does NOT carry an onRegenerate
|
||||
// wiring.
|
||||
expect(src).toMatch(
|
||||
/<HeroStrip[\s\S]*?onRegenerate=\{advisor\.regenerate\}/,
|
||||
/<InsightsTabStrip[\s\S]*?onRegenerate=\{advisor\.regenerate\}[\s\S]*?\/>/,
|
||||
);
|
||||
const heroBlock = src.match(/<HeroStrip[\s\S]*?\/>/);
|
||||
expect(heroBlock).not.toBeNull();
|
||||
expect(heroBlock?.[0]).not.toContain("onRegenerate");
|
||||
});
|
||||
|
||||
it("does NOT render the duplicate <TrendCard> tile strip on /insights", () => {
|
||||
|
||||
+17
-126
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -43,6 +42,7 @@ import {
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
|
||||
import { DailyBriefing } from "@/components/insights/daily-briefing";
|
||||
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
|
||||
import { TrendsRow } from "@/components/insights/trends-row";
|
||||
@@ -930,21 +930,21 @@ export default function InsightsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* v1.4.22 A5 — section tabs lift above the hero strip so the
|
||||
user sees the metric-tab nav before scrolling. The nav itself
|
||||
remains a sticky scroll-anchored strip — clicking a tab
|
||||
scrolls to the matching section, scrolling highlights the
|
||||
active tab. Hero + Daily Briefing always render below the
|
||||
nav; the metric tabs control which sub-sections are visible
|
||||
in the user's viewport. */}
|
||||
<InsightsSectionNav />
|
||||
{/* v1.4.25 W3 — section tabs + regenerate affordance live in the
|
||||
dedicated `<InsightsTabStrip>` component. The strip is sticky
|
||||
so the user can re-run the analysis without scrolling back to
|
||||
the hero band. The hero strip no longer carries its own
|
||||
regenerate button — the icon-only RefreshCw on this strip is
|
||||
the canonical page-level affordance. */}
|
||||
<InsightsTabStrip
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
/>
|
||||
|
||||
<HeroStrip
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
userName={heroGreetingName}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
onAskCoach={(prefill?: string) => {
|
||||
// The action-row button passes no prefill (drawer opens blank);
|
||||
// the Health Score panel passes a score-aware question. Both
|
||||
@@ -1659,117 +1659,8 @@ function MedicationComplianceCalendar({
|
||||
}
|
||||
|
||||
// ── Section Navigation ───────────────────────────────────────────────────────
|
||||
|
||||
// v1.4.25 W3 — "section-general" pill dropped alongside the
|
||||
// `<InsightStatusCard>` it scrolled to. The general status was
|
||||
// superseded by the always-on `<InsightAdvisorCard>` block above the
|
||||
// per-metric sections, so the pill had nothing to scroll to once the
|
||||
// status mount was removed.
|
||||
const SECTION_IDS = [
|
||||
"section-bp",
|
||||
"section-weight",
|
||||
"section-pulse",
|
||||
"section-mood",
|
||||
"section-meds",
|
||||
"section-bmi",
|
||||
] as const;
|
||||
|
||||
const SECTION_LABEL_KEYS: Record<(typeof SECTION_IDS)[number], string> = {
|
||||
"section-bp": "insights.navBloodPressure",
|
||||
"section-weight": "insights.navWeight",
|
||||
"section-pulse": "insights.navPulse",
|
||||
"section-mood": "insights.navMood",
|
||||
"section-meds": "insights.navMedication",
|
||||
"section-bmi": "insights.navBmi",
|
||||
};
|
||||
|
||||
function InsightsSectionNav() {
|
||||
const { t } = useTranslations();
|
||||
const [activeId, setActiveId] = useState<string>(SECTION_IDS[0]);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// v1.4.22 W5 reconcile (Code-MED-4) — pick the entry with the
|
||||
// highest intersectionRatio in the band rather than the
|
||||
// observer-supplied last entry. Three sections briefly
|
||||
// visible during a fast scroll otherwise made the active
|
||||
// pill jump to whichever one came last in the batch.
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
||||
const top = visible[0];
|
||||
if (top) {
|
||||
setActiveId((current) =>
|
||||
current === top.target.id ? current : top.target.id,
|
||||
);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-30% 0px -60% 0px" },
|
||||
);
|
||||
|
||||
for (const id of SECTION_IDS) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) observerRef.current.observe(el);
|
||||
}
|
||||
|
||||
return () => observerRef.current?.disconnect();
|
||||
}, []);
|
||||
|
||||
function scrollTo(id: string) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
// v1.4.22 W5 reconcile (Design-H1) — gate smooth scrolling behind
|
||||
// `prefers-reduced-motion`; honour the user's OS-level pref.
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
el.scrollIntoView({
|
||||
behavior: prefersReducedMotion ? "auto" : "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
// v1.4.22 W5 reconcile (Design-H1, Design-H3) — accessibility +
|
||||
// sticky-strip polish. Notable changes:
|
||||
// - aria-label so screen-reader landmark traversal hears the
|
||||
// rail's purpose ("Skip to section" / "Zu Abschnitt springen").
|
||||
// - bg-background/95 instead of /80 to kill the hero-glow bleed
|
||||
// during scroll.
|
||||
// - Drop the `-mx-4 / md:-mx-6` negative margin; the parent
|
||||
// container handles horizontal padding so 280px (Galaxy Fold)
|
||||
// doesn't get a ghost scrollbar.
|
||||
// - Hide the inner overflow's scrollbar so the sticky strip
|
||||
// reads as a single bar.
|
||||
<nav
|
||||
aria-label={t("insights.navAriaLabel")}
|
||||
className={cn(
|
||||
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
{SECTION_IDS.map((id) => {
|
||||
const isActive = activeId === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => scrollTo(id)}
|
||||
aria-current={isActive ? "location" : undefined}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(SECTION_LABEL_KEYS[id])}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
// v1.4.25 W3 — the legacy inline `InsightsSectionNav` was promoted to a
|
||||
// dedicated component at `src/components/insights/insights-tab-strip.tsx`
|
||||
// so the regenerate affordance could share the sticky strip with the
|
||||
// metric pills. The page just mounts `<InsightsTabStrip>` near the top
|
||||
// of the layout.
|
||||
@@ -143,34 +143,14 @@ describe("<HeroStrip>", () => {
|
||||
expect(coachTag?.[0]).not.toContain('title="Coming soon"');
|
||||
});
|
||||
|
||||
it("renders the regenerate button when onRegenerate is supplied", () => {
|
||||
const html = render(
|
||||
<HeroStrip briefing={null} now={morningLocal} onRegenerate={() => {}} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insights-hero-strip-action-rerun"/);
|
||||
expect(html).toContain("Re-run analysis");
|
||||
});
|
||||
|
||||
it("hides the regenerate button when no handler is supplied", () => {
|
||||
// v1.4.25 W3 — the regenerate button moved from the hero action row
|
||||
// to the dedicated `<InsightsTabStrip>` component. The hero strip no
|
||||
// longer renders the rerun slot under any prop combination.
|
||||
it("does NOT render a regenerate button in the hero action row", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
expect(html).not.toContain('data-slot="insights-hero-strip-action-rerun"');
|
||||
});
|
||||
|
||||
it("disables the regenerate button while regenerating", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
onRegenerate={() => {}}
|
||||
regenerating
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="insights-hero-strip-action-rerun"[^>]*disabled/,
|
||||
);
|
||||
expect(html).toContain("Regenerating");
|
||||
});
|
||||
|
||||
// ── Suggested prompts strip ────────────────────────────────────────
|
||||
it("mounts the SuggestedPrompts strip below the action band", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Share2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Download, FileText, Share2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
@@ -31,9 +24,11 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
* "Guten Morgen, …") + a narrative subtitle (the briefing
|
||||
* paragraph when one is cached, else a fallback)
|
||||
* - personal-baseline + "Generated <relative-time>" meta row
|
||||
* - 3-button action row: "Generate weekly report" (disabled, B4),
|
||||
* "Ask the coach" (disabled, B2), "Re-run analysis" (wired to
|
||||
* the existing regenerate handler from v1.4.16 D-reconcile)
|
||||
* - action row: "Generate weekly report" + "Ask the coach"
|
||||
* (v1.4.25 W3 dropped the third "Re-run analysis" button — the
|
||||
* regenerate affordance moved to `<InsightsTabStrip>` so the user
|
||||
* can re-run the analysis from any scroll position without
|
||||
* returning to the hero band).
|
||||
* - <SuggestedPrompts> chip strip below the action band
|
||||
* - Dracula gradient + soft purple glow via the new `.hero-gradient`
|
||||
* + `.glow-purple` utilities in `globals.css`
|
||||
@@ -42,8 +37,7 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
* wrap. Right-side Health Score panel is *not* part of B1 — that
|
||||
* lands in B5; for B1 the right side just keeps its meta band.
|
||||
*
|
||||
* Pure presentational — the page owns the briefing data + the
|
||||
* regenerate handler.
|
||||
* Pure presentational — the page owns the briefing data.
|
||||
*/
|
||||
|
||||
interface HeroStripProps {
|
||||
@@ -57,10 +51,6 @@ interface HeroStripProps {
|
||||
* morning, undefined".
|
||||
*/
|
||||
userName?: string | null;
|
||||
/** Click handler for the regenerate button — wired to the advisor query. */
|
||||
onRegenerate?: () => void;
|
||||
/** Disables the regenerate button + flips its icon to a spinner. */
|
||||
regenerating?: boolean;
|
||||
/**
|
||||
* Click handler for the suggested prompts. The Coach drawer (B2b)
|
||||
* routes a chip click into the drawer's input; the parent owns the
|
||||
@@ -141,8 +131,6 @@ export function HeroStrip({
|
||||
briefing,
|
||||
updatedAt,
|
||||
userName,
|
||||
onRegenerate,
|
||||
regenerating = false,
|
||||
onPickPrompt,
|
||||
onAskCoach,
|
||||
weeklyReportReady,
|
||||
@@ -291,28 +279,13 @@ export function HeroStrip({
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.heroActionAskCoach")}</span>
|
||||
</Button>
|
||||
{onRegenerate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="insights-hero-strip-action-rerun"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{regenerating
|
||||
? t("insights.heroRegenerating")
|
||||
: t("insights.heroActionRerun")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{/*
|
||||
* v1.4.25 W3 — the regenerate button moved to the new
|
||||
* `<InsightsTabStrip>` (icon-only RefreshCw, sticky next
|
||||
* to the pill nav) so the user can re-run the analysis
|
||||
* without scrolling back to the hero band. The hero's
|
||||
* action row is now Weekly-report + Ask-the-Coach only.
|
||||
*/}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3 — dedicated tab strip for `/insights`.
|
||||
*
|
||||
* Extracted from the inline `<InsightsSectionNav>` that lived inside
|
||||
* `src/app/insights/page.tsx` so the regenerate affordance can sit on
|
||||
* the same row as the metric pills. Up to v1.4.24 the regenerate
|
||||
* button lived inside the hero band's action row, which scrolled away
|
||||
* the moment the user started reading the per-metric cards. Marc's
|
||||
* directive (2026-05-14): the affordance has to stay reachable while
|
||||
* the user is deep in a section.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Six pills (BP / Weight / Pulse / Mood / Meds / BMI), each
|
||||
* scroll-anchored to the matching `<section id="section-{slug}">`
|
||||
* in the page. IntersectionObserver tracks which section is in
|
||||
* the active band so the matching pill highlights.
|
||||
* - Right slot: a 44×44 icon-only `<RefreshCw>` button. Clicking it
|
||||
* fires the `onRegenerate` handler the page hands down and pops a
|
||||
* sonner toast with `t("insights.regenerateSuccess")` once the
|
||||
* advisor query confirms. While the regenerate is in-flight the
|
||||
* icon swaps to a spinner and the button is disabled.
|
||||
*
|
||||
* Accessibility:
|
||||
* - `<nav aria-label>` mirrors the legacy nav's label so screen-reader
|
||||
* landmark traversal stays familiar ("Skip to section").
|
||||
* - Pill `aria-current="location"` flips with the scroll-anchor.
|
||||
* - The regenerate button has `aria-label={t("insights.regenerateAnalysis")}`
|
||||
* so the icon-only affordance is announced. Tooltip via `title`
|
||||
* mirrors the aria-label for sighted users.
|
||||
* - `prefers-reduced-motion` gates the smooth-scroll on pill clicks.
|
||||
*
|
||||
* Layout: sticky `top-0` strip with `backdrop-blur` and `bg-background/95`
|
||||
* — same surface treatment as the original `<InsightsSectionNav>` so
|
||||
* the visual rhythm of the page is unchanged.
|
||||
*/
|
||||
|
||||
export interface InsightsTabStripProps {
|
||||
/**
|
||||
* Handler the page passes from `useInsightsAdvisorQuery().regenerate`.
|
||||
* When supplied, the right-side icon button is enabled; when omitted,
|
||||
* the button hides so the strip stays clean on surfaces that don't
|
||||
* own the advisor query (e.g. a future preview).
|
||||
*/
|
||||
onRegenerate?: () => void;
|
||||
/** Spinner state — disables the button and swaps the icon. */
|
||||
regenerating?: boolean;
|
||||
}
|
||||
|
||||
const SECTION_IDS = [
|
||||
"section-bp",
|
||||
"section-weight",
|
||||
"section-pulse",
|
||||
"section-mood",
|
||||
"section-meds",
|
||||
"section-bmi",
|
||||
] as const;
|
||||
|
||||
const SECTION_LABEL_KEYS: Record<(typeof SECTION_IDS)[number], string> = {
|
||||
"section-bp": "insights.navBloodPressure",
|
||||
"section-weight": "insights.navWeight",
|
||||
"section-pulse": "insights.navPulse",
|
||||
"section-mood": "insights.navMood",
|
||||
"section-meds": "insights.navMedication",
|
||||
"section-bmi": "insights.navBmi",
|
||||
};
|
||||
|
||||
export function InsightsTabStrip({
|
||||
onRegenerate,
|
||||
regenerating = false,
|
||||
}: InsightsTabStripProps) {
|
||||
const { t } = useTranslations();
|
||||
const [activeId, setActiveId] = useState<string>(SECTION_IDS[0]);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
// Track which regenerate call we've already toasted so the success
|
||||
// toast only fires on the rising edge (true → false). Without this
|
||||
// the toast pops every render where `regenerating` is false.
|
||||
const lastRegeneratingRef = useRef<boolean>(regenerating);
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// v1.4.22 W5 reconcile (Code-MED-4) — pick the entry with the
|
||||
// highest intersectionRatio in the band rather than the
|
||||
// observer-supplied last entry. Three sections briefly
|
||||
// visible during a fast scroll otherwise made the active
|
||||
// pill jump to whichever one came last in the batch.
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
||||
const top = visible[0];
|
||||
if (top) {
|
||||
setActiveId((current) =>
|
||||
current === top.target.id ? current : top.target.id,
|
||||
);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-30% 0px -60% 0px" },
|
||||
);
|
||||
|
||||
for (const id of SECTION_IDS) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) observerRef.current.observe(el);
|
||||
}
|
||||
|
||||
return () => observerRef.current?.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Fire the success toast on the falling edge of `regenerating`
|
||||
// (rising-edge guard via the ref). Skips the toast on first
|
||||
// mount when the parent passes a "false" regenerating value.
|
||||
if (lastRegeneratingRef.current && !regenerating) {
|
||||
toast.success(t("insights.regenerateSuccess"));
|
||||
}
|
||||
lastRegeneratingRef.current = regenerating;
|
||||
}, [regenerating, t]);
|
||||
|
||||
function scrollTo(id: string) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
// v1.4.22 W5 reconcile (Design-H1) — gate smooth scrolling behind
|
||||
// `prefers-reduced-motion`; honour the user's OS-level pref.
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
el.scrollIntoView({
|
||||
behavior: prefersReducedMotion ? "auto" : "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
const regenerateLabel = t("insights.regenerateAnalysis");
|
||||
|
||||
return (
|
||||
<nav
|
||||
data-slot="insights-tab-strip"
|
||||
aria-label={t("insights.navAriaLabel")}
|
||||
className={cn(
|
||||
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 gap-2 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{SECTION_IDS.map((id) => {
|
||||
const isActive = activeId === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => scrollTo(id)}
|
||||
aria-current={isActive ? "location" : undefined}
|
||||
data-slot="insights-tab-strip-pill"
|
||||
data-active={isActive ? "true" : undefined}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(SECTION_LABEL_KEYS[id])}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{onRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
aria-label={regenerateLabel}
|
||||
title={regenerateLabel}
|
||||
data-slot="insights-tab-strip-regenerate"
|
||||
className={cn(
|
||||
// 44×44 touch target — Marc directive 2026-05-14. The
|
||||
// pill row above sits 28px tall; the button breaks the
|
||||
// band height intentionally so the touch target meets
|
||||
// the WCAG 2.5.5 minimum without forcing the pills to
|
||||
// grow.
|
||||
"inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent",
|
||||
"transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user