diff --git a/messages/de.json b/messages/de.json
index 7d0bc2052..3d0681aa0 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1330,7 +1330,6 @@
"loadError": "Zielwerte konnten nicht geladen werden"
},
"onboarding": {
- "welcome": "Willkommen bei HealthLog!",
"setupDescription": "Richte dein Profil ein, um loszulegen. Du kannst das auch später in den Einstellungen ändern.",
"completeSetup": "Einrichtung abschließen",
"skip": "Überspringen",
@@ -1429,9 +1428,28 @@
"step1of4": "Schritt 1 von 4",
"step2of4": "Schritt 2 von 4",
"step3of4": "Schritt 3 von 4",
- "step4of4": "Schritt 4 von 4",
- "welcomeTitle": "Willkommen bei HealthLog",
- "welcomeBody": "Vier kurze Schritte zum Einstieg. Jeden Schritt kannst du später in den Einstellungen wiederholen."
+ "step4of4": "Schritt 4 von 4"
+ },
+ "welcome": {
+ "title": "Willkommen bei HealthLog",
+ "carouselLabel": "Willkommen – drei Folien",
+ "slideOf": "Folie {current} von {total}",
+ "gotoSlide": "Zu Folie {index} wechseln",
+ "prevSlide": "Vorherige Folie",
+ "nextSlide": "Nächste Folie",
+ "slide1": {
+ "title": "Deine Gesundheitsdaten gehören dir",
+ "body": "HealthLog speichert deine Messwerte auf deiner Infrastruktur. Keine Werbung, keine Drittnutzung, kein Account-Zwang."
+ },
+ "slide2": {
+ "title": "Eine Ansicht für jede Quelle",
+ "body": "Apple Health, Withings und manuelle Einträge laufen in einem konsistenten Verlauf zusammen – inklusive Quellenpriorität pro Metrik."
+ },
+ "slide3": {
+ "title": "Coach und Insights mit Substanz",
+ "body": "Die KI-Auswertung stützt sich ausschließlich auf deine Werte und nennt jede Aussage mit Beleg. Bring-your-own-Key, keine Trainingsdaten."
+ },
+ "cta": "Loslegen"
},
"goals": {
"title": "Was möchtest du erfassen?",
diff --git a/messages/en.json b/messages/en.json
index 6c9733150..dff0df6dc 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1330,7 +1330,6 @@
"loadError": "Could not load thresholds"
},
"onboarding": {
- "welcome": "Welcome to HealthLog!",
"setupDescription": "Set up your profile to get started. You can change this later in Settings.",
"completeSetup": "Complete setup",
"skip": "Skip",
@@ -1429,9 +1428,28 @@
"step1of4": "Step 1 of 4",
"step2of4": "Step 2 of 4",
"step3of4": "Step 3 of 4",
- "step4of4": "Step 4 of 4",
- "welcomeTitle": "Welcome to HealthLog",
- "welcomeBody": "Set up the basics in four short steps. You can revisit any of them later from Settings."
+ "step4of4": "Step 4 of 4"
+ },
+ "welcome": {
+ "title": "Welcome to HealthLog",
+ "carouselLabel": "Welcome — three slides",
+ "slideOf": "Slide {current} of {total}",
+ "gotoSlide": "Go to slide {index}",
+ "prevSlide": "Previous slide",
+ "nextSlide": "Next slide",
+ "slide1": {
+ "title": "Your health data, owned by you",
+ "body": "HealthLog stores every reading on your own infrastructure. No ads, no third-party sharing, no account lock-in."
+ },
+ "slide2": {
+ "title": "One view across every source",
+ "body": "Apple Health, Withings and manual entries merge into a single consistent history — with per-metric source priority you control."
+ },
+ "slide3": {
+ "title": "Coach and Insights with substance",
+ "body": "Evidence-grounded analysis built strictly from your numbers, every claim citable. Bring-your-own-key, no training on your data."
+ },
+ "cta": "Get started"
},
"goals": {
"title": "What would you like to track?",
diff --git a/messages/es.json b/messages/es.json
index 49f588c10..7047899a2 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1330,7 +1330,6 @@
"loadError": "Could not load thresholds"
},
"onboarding": {
- "welcome": "Welcome to HealthLog!",
"setupDescription": "Set up your profile to get started. You can change this later in Settings.",
"completeSetup": "Complete setup",
"skip": "Omitir",
@@ -1429,9 +1428,28 @@
"step1of4": "Paso 1 de 4",
"step2of4": "Paso 2 de 4",
"step3of4": "Paso 3 de 4",
- "step4of4": "Paso 4 de 4",
- "welcomeTitle": "Te damos la bienvenida a HealthLog",
- "welcomeBody": "Configura lo esencial en cuatro pasos cortos. Puedes revisar cualquiera más tarde desde Ajustes."
+ "step4of4": "Paso 4 de 4"
+ },
+ "welcome": {
+ "title": "Te damos la bienvenida a HealthLog",
+ "carouselLabel": "Bienvenida — tres diapositivas",
+ "slideOf": "Diapositiva {current} de {total}",
+ "gotoSlide": "Ir a la diapositiva {index}",
+ "prevSlide": "Diapositiva anterior",
+ "nextSlide": "Diapositiva siguiente",
+ "slide1": {
+ "title": "Tus datos de salud, en tus manos",
+ "body": "HealthLog guarda cada medición en tu propia infraestructura. Sin publicidad, sin compartir con terceros, sin depender de una cuenta."
+ },
+ "slide2": {
+ "title": "Una sola vista para cada fuente",
+ "body": "Apple Health, Withings y la entrada manual se unen en un historial consistente, con prioridad de fuente por métrica que controlas tú."
+ },
+ "slide3": {
+ "title": "Coach e Insights con sustancia",
+ "body": "Un análisis basado solo en tus cifras, con cada afirmación verificable. Trae tu propia clave; tus datos no se usan para entrenar."
+ },
+ "cta": "Empezar"
},
"goals": {
"title": "¿Qué te gustaría registrar?",
diff --git a/messages/fr.json b/messages/fr.json
index 60e310622..903822987 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1330,7 +1330,6 @@
"loadError": "Could not load thresholds"
},
"onboarding": {
- "welcome": "Welcome to HealthLog!",
"setupDescription": "Set up your profile to get started. You can change this later in Settings.",
"completeSetup": "Complete setup",
"skip": "Ignorer",
@@ -1429,9 +1428,28 @@
"step1of4": "Étape 1 sur 4",
"step2of4": "Étape 2 sur 4",
"step3of4": "Étape 3 sur 4",
- "step4of4": "Étape 4 sur 4",
- "welcomeTitle": "Bienvenue sur HealthLog",
- "welcomeBody": "Configurez l'essentiel en quatre courtes étapes. Vous pourrez revenir sur chacune depuis les paramètres."
+ "step4of4": "Étape 4 sur 4"
+ },
+ "welcome": {
+ "title": "Bienvenue sur HealthLog",
+ "carouselLabel": "Bienvenue — trois diapositives",
+ "slideOf": "Diapositive {current} sur {total}",
+ "gotoSlide": "Aller à la diapositive {index}",
+ "prevSlide": "Diapositive précédente",
+ "nextSlide": "Diapositive suivante",
+ "slide1": {
+ "title": "Vos données de santé vous appartiennent",
+ "body": "HealthLog conserve chaque mesure sur votre propre infrastructure. Sans publicité, sans partage à des tiers, sans dépendance à un compte."
+ },
+ "slide2": {
+ "title": "Une seule vue pour toutes vos sources",
+ "body": "Apple Health, Withings et la saisie manuelle se rejoignent dans un historique cohérent, avec une priorité de source par métrique que vous contrôlez."
+ },
+ "slide3": {
+ "title": "Un coach et des insights pertinents",
+ "body": "Une analyse fondée sur vos chiffres, chaque conclusion étant traçable. Apportez votre propre clé, aucun entraînement sur vos données."
+ },
+ "cta": "Commencer"
},
"goals": {
"title": "Que souhaitez-vous suivre ?",
diff --git a/messages/it.json b/messages/it.json
index b75abacd3..e16223e5e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1330,7 +1330,6 @@
"loadError": "Could not load thresholds"
},
"onboarding": {
- "welcome": "Welcome to HealthLog!",
"setupDescription": "Set up your profile to get started. You can change this later in Settings.",
"completeSetup": "Complete setup",
"skip": "Salta",
@@ -1429,9 +1428,28 @@
"step1of4": "Passo 1 di 4",
"step2of4": "Passo 2 di 4",
"step3of4": "Passo 3 di 4",
- "step4of4": "Passo 4 di 4",
- "welcomeTitle": "Benvenuto su HealthLog",
- "welcomeBody": "Imposta l'essenziale in quattro brevi passi. Puoi rivederli tutti più tardi dalle impostazioni."
+ "step4of4": "Passo 4 di 4"
+ },
+ "welcome": {
+ "title": "Benvenuto su HealthLog",
+ "carouselLabel": "Benvenuto — tre slide",
+ "slideOf": "Slide {current} di {total}",
+ "gotoSlide": "Vai alla slide {index}",
+ "prevSlide": "Slide precedente",
+ "nextSlide": "Slide successiva",
+ "slide1": {
+ "title": "I tuoi dati di salute, di tua proprietà",
+ "body": "HealthLog conserva ogni misurazione sulla tua infrastruttura. Niente pubblicità, nessuna condivisione con terzi, nessun vincolo di account."
+ },
+ "slide2": {
+ "title": "Un'unica vista per ogni sorgente",
+ "body": "Apple Health, Withings e gli inserimenti manuali confluiscono in una cronologia coerente, con priorità di sorgente per metrica decisa da te."
+ },
+ "slide3": {
+ "title": "Coach e Insights con sostanza",
+ "body": "Un'analisi basata solo sui tuoi dati, con ogni affermazione tracciabile. Bring-your-own-key: nessun addestramento sui tuoi dati."
+ },
+ "cta": "Iniziamo"
},
"goals": {
"title": "Cosa vuoi monitorare?",
diff --git a/messages/pl.json b/messages/pl.json
index 685acbe6e..0135e7081 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1330,7 +1330,6 @@
"loadError": "Could not load thresholds"
},
"onboarding": {
- "welcome": "Welcome to HealthLog!",
"setupDescription": "Set up your profile to get started. You can change this later in Settings.",
"completeSetup": "Complete setup",
"skip": "Pomiń",
@@ -1429,9 +1428,28 @@
"step1of4": "Krok 1 z 4",
"step2of4": "Krok 2 z 4",
"step3of4": "Krok 3 z 4",
- "step4of4": "Krok 4 z 4",
- "welcomeTitle": "Witaj w HealthLog",
- "welcomeBody": "Skonfiguruj podstawy w czterech krótkich krokach. Każdy z nich możesz później powtórzyć z poziomu ustawień."
+ "step4of4": "Krok 4 z 4"
+ },
+ "welcome": {
+ "title": "Witaj w HealthLog",
+ "carouselLabel": "Powitanie — trzy slajdy",
+ "slideOf": "Slajd {current} z {total}",
+ "gotoSlide": "Przejdź do slajdu {index}",
+ "prevSlide": "Poprzedni slajd",
+ "nextSlide": "Następny slajd",
+ "slide1": {
+ "title": "Twoje dane zdrowotne należą do Ciebie",
+ "body": "HealthLog przechowuje każdy pomiar na Twojej infrastrukturze. Bez reklam, bez udostępniania osobom trzecim, bez przywiązywania do konta."
+ },
+ "slide2": {
+ "title": "Jeden widok dla wszystkich źródeł",
+ "body": "Apple Health, Withings i wpisy ręczne łączą się w spójną historię — z priorytetem źródła dla każdej metryki, który ustalasz sam."
+ },
+ "slide3": {
+ "title": "Coach i Insights z treścią",
+ "body": "Analiza oparta wyłącznie na Twoich liczbach, każde stwierdzenie jest weryfikowalne. Bring-your-own-key: Twoje dane nie służą do trenowania."
+ },
+ "cta": "Zaczynamy"
},
"goals": {
"title": "Co chcesz śledzić?",
diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
index 596584632..e22e2d55b 100644
--- a/src/app/onboarding/[step]/page.tsx
+++ b/src/app/onboarding/[step]/page.tsx
@@ -1,6 +1,7 @@
import { redirect, notFound } from "next/navigation";
import { OnboardingShell } from "@/components/onboarding/OnboardingShell";
+import { WelcomeCarousel } from "@/components/onboarding/WelcomeCarousel";
import { getSession } from "@/lib/auth/session";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -72,22 +73,23 @@ export default async function OnboardingStepPage({ params }: PageProps) {
const locale = await resolveServerLocale({ userLocale: user.locale });
const { t } = getServerTranslator(locale);
- // Foundation scaffold body — placeholder per-step copy. The Content
- // agent replaces each branch's body with the real carousel /
- // goal-chips / source-cards / baseline-form / done-screen UI.
- //
- // Welcome copy lives under `onboarding.shell.welcome{Title,Body}`
- // because the carousel is part of the chrome; every other step
- // owns its own namespace (`onboarding.goals.*`, `.source.*`,
- // `.baseline.*`, `.done.*`).
- const bodyTitle =
- requested === 0
- ? t("onboarding.shell.welcomeTitle")
- : t(`onboarding.${stepKey(requested)}.title`);
- const bodyText =
- requested === 0
- ? t("onboarding.shell.welcomeBody")
- : t(`onboarding.${stepKey(requested)}.body`);
+ // Welcome (step 0) renders the client-side value-prop carousel.
+ // The carousel owns its own primary CTA (POST /api/onboarding/step
+ // with step:1) so the shell drops `nextHref` to avoid a double CTA.
+ if (requested === 0) {
+ return (
+
+
+
+ );
+ }
+
+ // Foundation placeholder branches — replaced one step at a time by
+ // the W14b-Content agent. Each step's body resolves to the real i18n
+ // copy already, so the page reads end-to-end even before the per-
+ // step component lands.
+ const bodyTitle = t(`onboarding.${stepKey(requested)}.title`);
+ const bodyText = t(`onboarding.${stepKey(requested)}.body`);
// Wire shell navigation by step. Step 0 has no Back; the wizard
// never lets the user navigate forward past their current step, but
diff --git a/src/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/WelcomeCarousel.tsx
new file mode 100644
index 000000000..1466a8d06
--- /dev/null
+++ b/src/components/onboarding/WelcomeCarousel.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import { useCallback, useEffect, useId, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ ChevronLeft,
+ ChevronRight,
+ HeartPulse,
+ Plug,
+ Sparkles,
+ type LucideIcon,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+/**
+ * v1.4.25 W14b-Content — onboarding welcome carousel.
+ *
+ * Three value-prop slides shown on `/onboarding/0` before any data is
+ * asked of the user. Mirrors the Withings / Strava pattern surfaced in
+ * the W14b research file (§2.1, §2.5): pitch the product, then ask.
+ *
+ * Slides:
+ * 1. Tagline — "Your health data, owned by you."
+ * 2. Multi-source — Apple Health + Withings + manual, one view.
+ * 3. AI Coach + Insights — evidence-grounded, your data only.
+ *
+ * The carousel is a CSS scroll-snap rail; arrow buttons + dot pager
+ * mirror the active slide. `prefers-reduced-motion` disables the smooth
+ * scroll behaviour (per WCAG 2.3.3 / research §4.6).
+ *
+ * The primary CTA ("Get started") POSTs `/api/onboarding/step` with
+ * `{ step: 1 }`. On success it invalidates the `auth` query (so the
+ * user-shape's `onboardingStep` re-reads) and pushes to `/onboarding/1`.
+ */
+
+interface CarouselSlide {
+ id: "tagline" | "sources" | "coach";
+ Icon: LucideIcon;
+ titleKey: string;
+ bodyKey: string;
+}
+
+const SLIDES: ReadonlyArray = [
+ {
+ id: "tagline",
+ Icon: HeartPulse,
+ titleKey: "onboarding.welcome.slide1.title",
+ bodyKey: "onboarding.welcome.slide1.body",
+ },
+ {
+ id: "sources",
+ Icon: Plug,
+ titleKey: "onboarding.welcome.slide2.title",
+ bodyKey: "onboarding.welcome.slide2.body",
+ },
+ {
+ id: "coach",
+ Icon: Sparkles,
+ titleKey: "onboarding.welcome.slide3.title",
+ bodyKey: "onboarding.welcome.slide3.body",
+ },
+];
+
+export function WelcomeCarousel() {
+ const { t } = useTranslations();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+
+ const railRef = useRef(null);
+ const liveRegionId = useId();
+ const [active, setActive] = useState(0);
+ const [advancing, setAdvancing] = useState(false);
+
+ // Track which slide is centred in the rail. We observe each slide
+ // with an IntersectionObserver — simpler and more reliable than
+ // computing scroll-positions, and it handles touch flings naturally.
+ useEffect(() => {
+ const rail = railRef.current;
+ if (!rail) return;
+
+ const slides = Array.from(
+ rail.querySelectorAll("[data-slide-index]"),
+ );
+ if (slides.length === 0) return;
+
+ const io = new IntersectionObserver(
+ (entries) => {
+ // Pick the entry whose intersection ratio is highest — that's
+ // the slide currently snapped into view.
+ let best: { idx: number; ratio: number } | null = null;
+ for (const e of entries) {
+ const idx = Number((e.target as HTMLElement).dataset.slideIndex);
+ if (!Number.isFinite(idx)) continue;
+ if (!best || e.intersectionRatio > best.ratio) {
+ best = { idx, ratio: e.intersectionRatio };
+ }
+ }
+ if (best && best.ratio > 0.5) {
+ setActive(best.idx);
+ }
+ },
+ { root: rail, threshold: [0.5, 0.75, 1] },
+ );
+
+ for (const s of slides) io.observe(s);
+ return () => io.disconnect();
+ }, []);
+
+ const scrollToSlide = useCallback((idx: number) => {
+ const rail = railRef.current;
+ if (!rail) return;
+ const target = rail.querySelector(
+ `[data-slide-index="${idx}"]`,
+ );
+ if (!target) return;
+ const reduceMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)",
+ ).matches;
+ target.scrollIntoView({
+ behavior: reduceMotion ? "auto" : "smooth",
+ inline: "start",
+ block: "nearest",
+ });
+ }, []);
+
+ async function handleGetStarted() {
+ if (advancing) return;
+ setAdvancing(true);
+ try {
+ const res = await fetch("/api/onboarding/step", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ step: 1 }),
+ });
+ if (!res.ok) throw new Error(await readError(res));
+ await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ router.push("/onboarding/1");
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : t("onboarding.errorGeneric");
+ toast.error(message);
+ setAdvancing(false);
+ }
+ }
+
+ return (
+
+
+ {t("onboarding.welcome.title")}
+
+
+
+
+ {SLIDES.map((slide, idx) => (
+
+
+
+
+
+ {t(slide.titleKey)}
+
+
+ {t(slide.bodyKey)}
+
+
+ ))}
+
+
+ {/* Live region announces slide changes to AT users — matches the
+ tour's pattern (tour.tsx:374) so the focus contract is
+ consistent across the onboarding surface. */}
+