feat(insights): assessment and prose readability pass

- assessments render in full; the three-line clamp and its show-more
  toggle are gone
- AI prose (assessment, briefing, explainers, usual-range note) reads
  in the regular foreground; muted stays reserved for meta lines
- the usual-range note becomes a proper card with the shared TileHeader
  (icon + title, labelled in all six locales)
- the metric-page heading + explainer indent to the cards' text edge
- the health-score card drops its Ask-the-Coach corner button
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 13:27:30 +02:00
1 parent 8579705fbf
commit d83220a4e0
11 files changed
+36 -109

No files matched your search

+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} Tokens · {model}",
"askAboutThis": "Den Coach dazu fragen",
"readStrip": {
"label": "Coach-Einschätzung",
"label": "Üblicher Bereich",
"baselineWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darin.",
"baselineAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darüber.",
"baselineBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darunter.",
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} tokens · {model}",
"askAboutThis": "Ask the coach about this",
"readStrip": {
"label": "Coach read",
"label": "Usual range",
"baselineWithin": "Your usual range is {low}–{high} {unit}; today's {value} sits within it.",
"baselineAbove": "Your usual range is {low}–{high} {unit}; today's {value} sits above it.",
"baselineBelow": "Your usual range is {low}–{high} {unit}; today's {value} sits below it.",
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} tokens · {model}",
"askAboutThis": "Pregúntale al coach sobre esto",
"readStrip": {
"label": "Lectura del Coach",
"label": "Rango habitual",
"baselineWithin": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa dentro de él.",
"baselineAbove": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por encima.",
"baselineBelow": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por debajo.",
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} jetons · {model}",
"askAboutThis": "Interroger le coach à ce sujet",
"readStrip": {
"label": "Lecture du Coach",
"label": "Plage habituelle",
"baselineWithin": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} s'y situe.",
"baselineAbove": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe au-dessus.",
"baselineBelow": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe en dessous.",
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} token · {model}",
"askAboutThis": "Chiedi al coach",
"readStrip": {
"label": "Lettura del Coach",
"label": "Intervallo abituale",
"baselineWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} rientra al suo interno.",
"baselineAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sopra.",
"baselineBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sotto.",
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} tokenów · {model}",
"askAboutThis": "Zapytaj trenera o to",
"readStrip": {
"label": "Ocena Coacha",
"label": "Typowy zakres",
"baselineWithin": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} mieści się w nim.",
"baselineAbove": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest powyżej niego.",
"baselineBelow": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest poniżej niego.",
@@ -8,7 +8,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
import { TileHeader } from "@/components/insights/tile-header";
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
/**
@@ -109,37 +109,27 @@ export function CoachReadStrip({
// fires defensively.)
if (!baselineLine && !driverLine) return null;
// Standard card anatomy — the same TileHeader-led shape as every other
// insights card (foreground icon + title), instead of the former inline
// mini-icon strip that read as chrome outside the card system. Body prose
// in the regular foreground; muted stays reserved for meta lines.
return (
<div
data-slot="coach-read-strip"
className={cn(
"bg-card/60 border-border/60 rounded-xl border px-3.5 py-2.5",
"text-muted-foreground space-y-1 text-sm leading-relaxed",
)}
className="bg-card/60 border-border/60 space-y-2 rounded-xl border px-4 py-3.5"
>
<div className="flex items-start gap-2">
<Sparkles
className="text-primary/70 mt-0.5 size-3.5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<span className="text-foreground/70 sr-only text-xs font-medium">
{t("insights.coach.readStrip.label")}
</span>
{baselineLine ? (
<p data-slot="coach-read-baseline" className="text-pretty">
{baselineLine}
</p>
) : null}
{driverLine ? (
<p
data-slot="coach-read-driver"
className="text-muted-foreground/90 text-pretty"
>
{driverLine}
</p>
) : null}
</div>
<TileHeader icon={Sparkles} title={t("insights.coach.readStrip.label")} />
<div className="text-foreground min-w-0 space-y-1 text-sm leading-relaxed">
{baselineLine ? (
<p data-slot="coach-read-baseline" className="text-pretty">
{baselineLine}
</p>
) : null}
{driverLine ? (
<p data-slot="coach-read-driver" className="text-pretty">
{driverLine}
</p>
) : null}
</div>
</div>
);
@@ -13,7 +13,6 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
import { AskCoachAction } from "./ask-coach-action";
/**
* v1.4.20 B5 — Personal Health Score panel.
@@ -703,16 +702,6 @@ export function HealthScoreCard({
{/* v1.18.6 (DISC-01) — the "indicative, not a clinical assessment"
card disclaimer is removed; the one-time onboarding acknowledgment
now covers the not-a-diagnosis framing app-wide. */}
{/* v1.21.0 (C4 H2) — the score is a whole-picture composite, so the
hand-off opens the Coach against the default snapshot (no scope)
seeded with the actual number. Mounts at the foot of the card as
a single discreet entry, not a primary CTA. */}
<div className="mt-auto flex justify-end pt-1">
<AskCoachAction
question={t("insights.coach.seed.healthScore", { score })}
/>
</div>
</div>
</div>
);
+1 -1
View File
@@ -300,7 +300,7 @@ export function HeroStrip({
spacing (still plain text children, no markdown). */}
<div
data-slot="insights-hero-strip-subtitle"
className="text-muted-foreground max-w-3xl text-sm"
className="text-foreground max-w-3xl text-sm"
>
<ProseBlocks text={subtitle} />
</div>
@@ -1,6 +1,5 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
@@ -287,67 +286,13 @@ export function InsightStatusCard({
* overflow, and re-measures on resize / font load via `ResizeObserver`.
*/
function StatusBody({ text }: { text: string }) {
const { t } = useTranslations();
const [expanded, setExpanded] = useState(false);
const [overflows, setOverflows] = useState(false);
const wrapperRef = useRef<HTMLDivElement | null>(null);
// v1.22 (W6) — the assessment now renders through the shared `ProseBlocks`
// helper (real paragraphs). The 3-line clamp therefore moves from the single
// `<p>` to the BLOCK CONTAINER: a max-height + overflow-hidden when collapsed,
// measured on the wrapper node so multi-paragraph text doesn't re-collapse.
// When the text fits, `scrollHeight` equals `clientHeight`; an overflow means
// the clamp is hiding content and the toggle earns its place. Gated on
// `!expanded` so the comparison reads against the clamped element.
useEffect(() => {
if (expanded) return;
const node = wrapperRef.current;
if (!node) return;
const measure = () => {
setOverflows(node.scrollHeight > node.clientHeight + 1);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
// Re-measure whenever the source text changes (a fresh assessment) or
// the user collapses the card again.
}, [text, expanded]);
// The assessment shows in full — the 3-line clamp + "show more" toggle
// hid the prose behind an extra tap for no gain on a card whose whole
// point is the text. Body prose reads in the regular foreground; muted
// stays reserved for meta lines (timestamps, captions).
return (
<div className="space-y-1">
<div
ref={wrapperRef}
className={cn(
"text-muted-foreground text-sm",
// ~3 lines at text-sm / leading-relaxed; only when collapsed.
!expanded && "max-h-[4.5rem] overflow-hidden",
)}
>
<ProseBlocks text={text} />
</div>
{/* Only render the toggle on a genuine overflow. When the full text
fits inside three lines there is nothing more to show, so no
affordance is painted. */}
{(overflows || expanded) && (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
data-slot="assessment-show-more"
className={cn(
"text-foreground/80 hover:text-foreground inline-flex text-xs font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{expanded
? t("insights.assessmentShowLess")
: t("insights.assessmentShowMore")}
</button>
)}
<div className="text-foreground text-sm">
<ProseBlocks text={text} />
</div>
);
}
+5 -2
View File
@@ -218,7 +218,10 @@ export function SubPageShell({
<div data-slot="insights-subpage" className="space-y-3 md:space-y-4">
{/* v1.8.7.1 — back-nav leads the page, above the heading. */}
{backLink}
<header className="space-y-1.5">
{/* The heading + explainer indent to the cards' inner text edge
(`px-6`, the Card content padding), so the page head reads as
part of the card column instead of hanging outside it. */}
<header className="space-y-1.5 px-6">
{/* v1.8.6 — the heading row pins the Coach icon top-right while the
title / nudge / badge wrap together on the left.
@@ -337,7 +340,7 @@ export function SubPageShell({
// string was removed.
<p
data-slot="metric-explainer-inline"
className="text-muted-foreground text-sm leading-relaxed"
className="text-foreground text-sm leading-relaxed"
>
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
</p>