feat(coach): render AI prose as real paragraphs with clickable Learn links

Add a shared, pure ProseBlocks helper that splits trusted plain text on
blank lines into <p> blocks and single newlines into <br>, with no markdown
library and no dangerouslySetInnerHTML. A catalog-whitelisted linkifier turns
grounded /learn/<slug> references into safe anchors via learn-more-link;
unknown slugs stay plain text. Rewrite StreamedProse to be streaming-safe:
completed paragraphs settle while only the growing tail animates word by
word.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:31:03 +02:00
1 parent 7d3c528157
commit 16ab522a06
4 files changed
+431 -39

No files matched your search

+49 -1
View File
@@ -1,8 +1,9 @@
"use client";
import type { ReactNode } from "react";
import { ExternalLink } from "lucide-react";
import { learnLinkForMetric } from "@/lib/learn-links";
import { learnLinkForMetric, learnUrl } from "@/lib/learn-links";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -55,3 +56,50 @@ export function LearnMoreLink({ concept, className }: LearnMoreLinkProps) {
</a>
);
}
/**
* v1.22 (W5) — inline, catalog-whitelisted Learn anchor for AI prose.
*
* Where `LearnMoreLink` resolves a *concept* to its guide for a deterministic
* UI surface, this variant linkifies a `/learn/<slug>` reference that already
* appears in trusted prose (the Coach reply, post-filtered server-side by
* `scrubUnknownLearnLinks`). The slug is resolved through `learnUrl` — the same
* closed-set, registry-backed builder — so an unknown slug renders the children
* as plain text (fail-closed): a drifted post-filter can never produce a
* clickable invented href, and there is no markdown / HTML parsing involved.
*
* The visible label is the caller's `children` (the original URL text), keeping
* the reference verbatim; the safe attributes (`target="_blank"`,
* `rel="noopener noreferrer"`) mirror `LearnMoreLink`.
*/
export interface InlineLearnLinkProps {
/** The `/learn/<slug>` path segment to resolve and link. */
slug: string;
/** Visible anchor text (typically the matched URL substring). */
children: ReactNode;
className?: string;
}
export function InlineLearnLink({
slug,
children,
className,
}: InlineLearnLinkProps) {
const url = learnUrl(slug);
if (url == null) return <>{children}</>;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
data-slot="inline-learn-link"
className={cn(
"text-primary underline decoration-from-font underline-offset-2 hover:no-underline",
className,
)}
>
{children}
</a>
);
}