feat(self-description): add the non-diagnostic / intended-purpose standard

Introduce one canonical "what this is / is not, not a medical device"
statement plus a layered, calm disclaimer set, and apply it on the
surfaces that earn a context note.

- New shared `MedicalDisclaimer` component renders the copy as plain
  React text (no markdown renderer, per the XSS posture). Variants:
  intendedPurpose, coach, insights, labs, medications, emergency,
  dataPosture.
- New `selfDescription` i18n namespace across all six locales: the
  intended-purpose line, the per-surface notes, the emergency note, and
  an explicit data-posture statement (self-hosted, data stays on your
  instance, encrypted at rest, BYOK / local model, no third-party
  analytics).
- Apply once per surface, layered not plastered: a calm standing line on
  the Coach new-chat hero, one framing line in the Insights layout shell
  (covers the briefing and trends), a labs note (ranges vary, only a
  clinician interprets), a neutral medications note, and the data-posture
  line where the provider key is configured. Mental-wellbeing keeps its
  existing crisis copy; records keep their existing note.
- Extend the Coach system prompt (EN + DE) with a self-description ground
  rule so the Coach answers "what are you / where does my data go"
  honestly and stays non-diagnostic.

App-authored copy was already observational; the few diagnose/treat
tokens are negations, idioms, or framed as the user's own self-reported
statement, so no softening was required.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 17:43:31 +02:00
1 parent a27a1ee71d
commit 3bd52a322b
13 files changed
+195 -5

No files matched your search

@@ -0,0 +1,75 @@
"use client";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.25 — the one place the app states what it is and is not.
*
* HealthLog's self-description standard is layered, not a wall of warnings:
* a single intended-purpose statement plus calm, context-specific notes that
* sit once on the surface they belong to. Every string is plain React text —
* no markdown renderer, in line with the project's XSS posture (Coach,
* briefing and insights all render as text children).
*
* The copy lives under the `selfDescription` i18n namespace so every locale
* carries the same set; pick the note that fits the surface:
*
* - `intendedPurpose` — the canonical "what this is / is not, not a medical
* device" line. Reuse verbatim; do not paraphrase per surface.
* - `coach` — the Coach standing line (works from your data, not a doctor).
* - `insights` — descriptive-patterns framing for insights + the briefing.
* - `labs` — your own records, ranges vary, only a clinician interprets.
* - `medications` — tracks what you log, not dosing/interaction advice.
* - `emergency` — the red-flag interstitial note (contact emergency services).
* - `dataPosture` — self-hosted, data stays on your instance, BYOK / local AI.
*
* The acknowledged onboarding disclaimer (`onboarding.disclaimer.*`) and the
* legal medical-device boundary on the public privacy page remain the standing
* floor; these notes are the gentle, in-context layer above it.
*/
export type DisclaimerVariant =
| "intendedPurpose"
| "coach"
| "insights"
| "labs"
| "medications"
| "emergency"
| "dataPosture";
const VARIANT_KEY: Record<DisclaimerVariant, string> = {
intendedPurpose: "selfDescription.intendedPurpose",
coach: "selfDescription.coach",
insights: "selfDescription.insights",
labs: "selfDescription.labs",
medications: "selfDescription.medications",
emergency: "selfDescription.emergency",
dataPosture: "selfDescription.dataPosture",
};
export interface MedicalDisclaimerProps {
variant: DisclaimerVariant;
className?: string;
}
/**
* A calm, muted, plain-text disclaimer line. Defaults to a small,
* unobtrusive footnote treatment; pass `className` to tune spacing for the
* surface. Keep it to one instance per surface — the standard is layered,
* not repeated.
*/
export function MedicalDisclaimer({
variant,
className,
}: MedicalDisclaimerProps) {
const { t } = useTranslations();
return (
<p
data-slot="medical-disclaimer"
data-variant={variant}
className={cn("text-muted-foreground text-xs leading-relaxed", className)}
>
{t(VARIANT_KEY[variant])}
</p>
);
}
@@ -2,6 +2,7 @@
import { Sparkles } from "lucide-react";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -121,6 +122,15 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
{scopeHint}
</div>
) : null}
{/* v1.25 — the Coach standing self-description: works from your own
data, not a doctor, not medical advice. Sits once on the calm
new-chat surface so it frames the conversation without nagging
every reply. */}
<MedicalDisclaimer
variant="coach"
className="max-w-md text-center text-pretty"
/>
</div>
</div>
);
@@ -8,6 +8,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import {
nextAdvisorPollInterval,
useInsightsAdvisorQuery,
@@ -159,11 +160,14 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
modules={user?.modules}
/>
{children}
{/* v1.18.6 (DISC-01) — the page-level generic medical disclaimer is
removed. The "HealthLog is a private tracking tool, not a clinical
assessment or diagnosis" acknowledgment now lives once at the start
of onboarding, and the legal text stays reachable on the public
privacy page. */}
{/* v1.18.6 (DISC-01) — the page-level generic medical disclaimer was
removed; the standing acknowledgment lives once at the start of
onboarding and the legal text on the public privacy page.
v1.25 — one calm, context-specific framing line for the whole
Insights surface (the briefing, trends and correlations included):
these are descriptive patterns from the user's own data, not a
clinical assessment. Layered, not a wall of warnings. */}
<MedicalDisclaimer variant="insights" className="px-1" />
</div>
);
}
@@ -19,6 +19,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -201,6 +202,11 @@ export function AiInsightsCard({
})
}
/>
{/* v1.25 — the data-posture statement lives where the provider key is
configured: self-hosted, data stays on this instance, BYOK / local
model, no third-party analytics. One calm line, not a banner. */}
<MedicalDisclaimer variant="dataPosture" />
</div>
</SettingsCard>
);