merge(LEARNUI): Learn-links registry, component, and surface wiring

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:23:28 +02:00
commit 03e1935c8a
14 files changed
+269 -6

No files matched your search

@@ -62,6 +62,12 @@ export interface SparklineDeltaTileProps {
* explainer; the tile just gives it a slot.
*/
provenance?: React.ReactNode;
/**
* Optional quiet footer slot — a discreet pointer (e.g. a `LearnMoreLink`)
* rendered under the framing row. Plain element children; the caller decides
* whether a tile carries one, so the dense grid stays uncluttered.
*/
footer?: React.ReactNode;
className?: string;
}
@@ -77,6 +83,7 @@ export function SparklineDeltaTile({
staleDays = null,
precision = 1,
provenance,
footer,
className,
}: SparklineDeltaTileProps) {
const { t } = useTranslations();
@@ -244,6 +251,12 @@ export function SparklineDeltaTile({
{t("dashboard.staleHint", { count: staleDays })}
</span>
)}
{footer ? (
<div className="mt-2" data-slot="sparkline-delta-tile-footer">
{footer}
</div>
) : null}
</div>
);
}
@@ -14,6 +14,7 @@ import {
import { TileHeader } from "@/components/insights/tile-header";
import { SectionHeading } from "@/components/insights/section-heading";
import { SparklineDeltaTile } from "./sparkline-delta-tile";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { CoverageMeter } from "./coverage-meter";
import { ProvenanceExplainer } from "./provenance-explainer";
import { METRIC_PROVENANCE } from "./standards";
@@ -289,6 +290,7 @@ function BaselineTile({
provenance={
<MetricProvenance metric={metric} provenance={data.provenance} />
}
footer={<LearnMoreLink concept={tileId} />}
/>
</div>
);
@@ -6,6 +6,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
@@ -117,6 +118,9 @@ export function GlucoseClinicalPanel() {
<div className="mt-4">
<LabsCrossLink />
</div>
<div className="mt-3">
<LearnMoreLink concept="BLOOD_GLUCOSE" />
</div>
</CardContent>
</Card>
);
@@ -237,6 +241,7 @@ export function GlucoseClinicalPanel() {
) : null}
<LabsCrossLink />
<LearnMoreLink concept="BLOOD_GLUCOSE" />
</CardContent>
</Card>
);
@@ -12,6 +12,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
/**
* v1.19.2 — calm readout for Oura's daily resilience level.
@@ -129,6 +130,7 @@ export function ResilienceTile() {
{t(TREND_KEYS[trendKey])}
</p>
) : null}
<LearnMoreLink concept="RESILIENCE" />
</CardContent>
</Card>
);
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
@@ -227,6 +228,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
<p className="text-muted-foreground text-sm">{marker.context}</p>
) : null}
<LearnMoreLink concept="LAB_BIOMARKER" />
<Card>
<CardContent className="pt-6">
{isLoading ? (
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { ExternalLink } from "lucide-react";
import { learnLinkForMetric } from "@/lib/learn-links";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.21.0 — discreet "Learn more" pointer to a public /learn guide.
*
* One small, calm anchor (no new colour, no card chrome) that links a concept
* out to its plain-language guide. Fail-closed by construction:
*
* - the href is resolved through `learnLinkForMetric` — a closed-set,
* registry-backed URL, never user/model content — so there is no XSS
* surface and no invented link;
* - an unmapped concept resolves to `null` and the component renders
* nothing, so a surface that wires a concept with no guide simply shows
* no pointer rather than a broken one.
*
* It is a literal `<a>` (no markdown library): `target="_blank"` with
* `rel="noopener noreferrer"`, the guide title as the accessible label.
*/
export interface LearnMoreLinkProps {
/**
* A `LearnConcept` key (or any metric id) to resolve. Anything unmapped
* renders nothing.
*/
concept: string;
className?: string;
}
export function LearnMoreLink({ concept, className }: LearnMoreLinkProps) {
const { t } = useTranslations();
const guide = learnLinkForMetric(concept);
if (guide == null) return null;
const label = t("common.learnMore");
return (
<a
href={guide.url}
target="_blank"
rel="noopener noreferrer"
data-slot="learn-more-link"
aria-label={`${label}: ${guide.title}`}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 transition-colors hover:underline",
className,
)}
>
{label}
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
</a>
);
}