diff --git a/src/components/insights/__tests__/prose-blocks.test.tsx b/src/components/insights/__tests__/prose-blocks.test.tsx
new file mode 100644
index 000000000..65c20860d
--- /dev/null
+++ b/src/components/insights/__tests__/prose-blocks.test.tsx
@@ -0,0 +1,130 @@
+import { describe, it, expect } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import {
+ ProseBlocks,
+ splitParagraphs,
+} from "@/components/insights/prose-blocks";
+import {
+ StreamedProse,
+ splitProseSegments,
+} from "@/components/insights/coach-panel/streamed-prose";
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("splitParagraphs", () => {
+ it("splits on blank lines and trims, dropping empties", () => {
+ expect(splitParagraphs("one\n\ntwo")).toEqual(["one", "two"]);
+ expect(splitParagraphs(" a \n\n\n b ")).toEqual(["a", "b"]);
+ expect(splitParagraphs("solo")).toEqual(["solo"]);
+ expect(splitParagraphs("\n\n \n\n")).toEqual([]);
+ });
+
+ it("keeps a single newline inside one paragraph", () => {
+ expect(splitParagraphs("line1\nline2")).toEqual(["line1\nline2"]);
+ });
+});
+
+describe("ProseBlocks", () => {
+ it("renders blank-line-separated text as real
blocks", () => {
+ const html = render();
+ const paras = (html.match(/
", () => {
+ const html = render();
+ expect(html).toContain("
");
+ expect((html.match(/ {
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain("metric:WEIGHT");
+ expect(html).toContain("Your weight");
+ });
+
+ it("linkifies a catalog-known /learn/ reference as a safe anchor", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="inline-learn-link"');
+ expect(html).toContain("/learn/resting-heart-rate");
+ expect(html).toContain('target="_blank"');
+ expect(html).toContain('rel="noopener noreferrer"');
+ });
+
+ it("leaves an unknown /learn slug as plain text (fail-closed)", () => {
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain('data-slot="inline-learn-link"');
+ expect(html).toContain("/learn/totally-made-up");
+ });
+
+ it("does not strip or linkify when both are disabled (user text)", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("metric:WEIGHT");
+ expect(html).not.toContain('data-slot="inline-learn-link"');
+ });
+});
+
+describe("StreamedProse", () => {
+ it("settles to real paragraph blocks when not streaming", () => {
+ const html = render(
+ ,
+ );
+ expect((html.match(/ {
+ const html = render(
+ ,
+ );
+ // The completed first paragraph settles as plain text (no spans).
+ expect(html).toContain("Settled paragraph.");
+ // The tail renders each word in its own fade-in span.
+ for (const word of ["Growing ", "tail ", "words"]) {
+ expect(html).toContain(`>${word}`);
+ }
+ });
+
+ it("does not leak a chart token into streamed prose", () => {
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain("metric:WEIGHT");
+ });
+});
+
+describe("splitProseSegments", () => {
+ it("keeps each word with its trailing whitespace", () => {
+ expect(splitProseSegments("a b c")).toEqual(["a ", "b ", "c"]);
+ expect(splitProseSegments("")).toEqual([]);
+ });
+});
diff --git a/src/components/insights/coach-panel/streamed-prose.tsx b/src/components/insights/coach-panel/streamed-prose.tsx
index 370d70a43..7b41f4df0 100644
--- a/src/components/insights/coach-panel/streamed-prose.tsx
+++ b/src/components/insights/coach-panel/streamed-prose.tsx
@@ -4,22 +4,37 @@ import { useMemo } from "react";
import { cn } from "@/lib/utils";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
+import {
+ ProseBlocks,
+ ParagraphText,
+ splitParagraphs,
+ PROSE_PARAGRAPH_CLASS,
+} from "@/components/insights/prose-blocks";
/**
* v1.18.9 — Claude-style word-by-word fade-in for the LIVE Coach turn.
*
* The provider clients return the whole reply in one shot; the chat route
* re-chunks it into per-word `token` SSE frames (`tokeniseForStreaming`),
- * so `content` grows as frames land. This component renders each
- * whitespace segment as its own `` that fades + drifts up on mount,
- * giving the reply a "thinking out loud" cadence rather than a hard dump
- * of word groups. Once the turn settles (`streaming === false`) the spans
- * collapse to a single plain-text node — zero residual animation DOM, and
- * the feedback / evidence layout underneath stays stable.
+ * so `content` grows as frames land. While the turn is streaming each
+ * whitespace segment of the GROWING tail renders as its own `` that
+ * fades + drifts up on mount, giving the reply a "thinking out loud"
+ * cadence rather than a hard dump of word groups.
*
- * Why no "only animate the new words" bookkeeping: each segment is keyed
- * by its index, so when a fresh SSE frame appends words, React keeps the
- * already-mounted spans (their CSS entrance already ran and does not
+ * v1.22 (W5) — paragraph-aware + SSE-safe. The reply now renders as real
+ * `` blocks (shared `ProseBlocks`), so a model that emits blank-line
+ * paragraph breaks reads as paragraphs, not one run-on block — consistent
+ * with the daily briefing + insight cards. Under streaming, every COMPLETE
+ * paragraph settles immediately (plain text, with the safe Learn linkifier)
+ * and only the LAST, still-growing paragraph animates word-by-word. When a
+ * fresh `\n\n` arrives the tail becomes a completed paragraph and stops
+ * animating; the already-settled paragraphs keep their index keys and never
+ * replay. Once the whole turn settles (`streaming === false`) it collapses
+ * to the same `ProseBlocks` render the persisted history uses.
+ *
+ * Why no "only animate the new words" bookkeeping: each tail segment is
+ * keyed by its index, so when a fresh SSE frame appends words React keeps
+ * the already-mounted spans (their CSS entrance already ran and does not
* replay on reconcile) and only MOUNTS the new trailing spans — which is
* exactly when the `animate-in` entrance fires. The browser does the
* "new words only" gating for us; no render-time ref is needed.
@@ -30,8 +45,8 @@ import { stripChartTokens } from "@/lib/insights/chart-tokens";
* attributes are already permitted under the strict CSP
* (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected
* `