diff --git a/src/__tests__/score-pill-guard.test.ts b/src/__tests__/score-pill-guard.test.ts
new file mode 100644
index 000000000..e77314137
--- /dev/null
+++ b/src/__tests__/score-pill-guard.test.ts
@@ -0,0 +1,98 @@
+/**
+ * A grade chip goes through `ScorePill` (`src/components/ui/score-pill.tsx`).
+ *
+ * Eleven chips that state a grade beside a reading (confidence, score gain,
+ * band, lab range) were hand-rolled in two looks: a /15 wash without border
+ * in the crosstabs, a /10 wash with a /40 border on the correlation and trend
+ * cards, and the middle confidence step was blue on one and amber on the
+ * other. This guard keeps a new copy of the chip from appearing beside the
+ * primitive.
+ *
+ * Matched: one string literal that combines the pill shape (`rounded-full`,
+ * a small padding `px-1.5|2|2.5`, `text-xs|2xs`) with a semantic tone wash
+ * (`bg-success|info|warning|destructive/10|15`). A neutral `bg-muted` chip is
+ * `TagChip`'s business, not this guard's.
+ *
+ * Not matched: a tone assembled at runtime from a map whose entries live in
+ * another literal. The guard catches the idiom, not every way to paint one.
+ */
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(__dirname, "..");
+
+const ALLOWED: Record = {
+ // A link to the release notes styled as a call to action, not a grade.
+ "components/admin/version-tile-section.tsx":
+ "update-available link, not a grade",
+};
+
+const LITERAL_RE = /["'`]([^"'`\n]*\brounded-full\b[^"'`\n]*)["'`]/g;
+
+function isGradeChip(cls: string): boolean {
+ return (
+ /(?:^|\s)px-(?:1\.5|2|2\.5)(?:\s|$)/.test(cls) &&
+ /(?:^|\s)text-(?:xs|2xs)(?:\s|$)/.test(cls) &&
+ /(?:^|\s)bg-(?:success|info|warning|destructive)\/1[05](?:\s|$)/.test(cls)
+ );
+}
+
+function findGradeChips(files: { path: string; source: string }[]): string[] {
+ const hits = new Set();
+ for (const { path, source } of files) {
+ for (const match of source.matchAll(LITERAL_RE)) {
+ if (isGradeChip(match[1])) hits.add(path);
+ }
+ }
+ return [...hits].sort();
+}
+
+describe("grade chips go through ScorePill", () => {
+ const files = walkSourceFiles(SRC, { floor: 2500 })
+ .filter(
+ (p) =>
+ (p.startsWith("components/") || p.startsWith("app/")) &&
+ !p.startsWith("components/ui/") &&
+ !p.includes("__tests__/"),
+ )
+ .map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") }));
+
+ it("walks the UI tree", () => {
+ expect(files.length).toBeGreaterThan(1000);
+ });
+
+ it("has no hand-rolled grade chip outside the allowlist", () => {
+ expect(findGradeChips(files).filter((p) => !(p in ALLOWED))).toEqual([]);
+ });
+
+ it("keeps every allowlist entry live", () => {
+ const hits = new Set(findGradeChips(files));
+ for (const key of Object.keys(ALLOWED))
+ expect(hits.has(key), key).toBe(true);
+ });
+
+ it("recognises the idiom it exists to refuse", () => {
+ expect(
+ findGradeChips([
+ {
+ path: "components/a.tsx",
+ source:
+ 'const C = { high: "bg-success/15 text-success" }; ',
+ },
+ {
+ path: "components/b.tsx",
+ source:
+ '',
+ },
+ ]),
+ ).toEqual(["components/a.tsx"]);
+ });
+
+ it("is used where the copies were", () => {
+ const users = files.filter((f) => f.source.includes("` and `` paint a discrete
- * "low / moderate / high" badge with byte-identical semantic utility
- * classes. Per-component i18n key maps stay local because the copy
- * differs (correlationRow.* vs trendAnnotation.*); only the classname
- * table is the load-bearing duplication.
- */
-export type ConfidenceBand = "low" | "moderate" | "high";
-
-export const CONFIDENCE_BADGE_CLASS: Record = {
- high: "border-success/40 bg-success/10 text-success",
- moderate: "border-warning/40 bg-warning/10 text-warning",
- // Neutral (non-status) tier. The former `*-dracula-comment/*` classes
- // had no generated utility (`--color-dracula-comment` was never in the
- // @theme block) and silently painted nothing; border/muted is the real
- // neutral vocabulary.
- low: "border-border bg-muted/50 text-muted-foreground",
-};
diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx
index da3ba4eb6..1e96315e6 100644
--- a/src/components/insights/correlation-card.tsx
+++ b/src/components/insights/correlation-card.tsx
@@ -2,7 +2,6 @@
import dynamic from "next/dynamic";
import { Activity, Sparkles } from "lucide-react";
-import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
@@ -12,7 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { CorrelationResult } from "@/lib/insights/correlations";
import type { CoachScopeSource } from "@/lib/ai/coach/types";
-import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
+import { CONFIDENCE_TONE, ScorePill } from "@/components/ui/score-pill";
import { AskCoachAction } from "./ask-coach-action";
import {
PatternDismissButton,
@@ -134,16 +133,12 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
title={title}
right={
result.status === "ok" ? (
-
{t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])}
-
+
) : undefined
}
/>
diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx
index eaf1715bf..182082e0e 100644
--- a/src/components/insights/health-score-card.tsx
+++ b/src/components/insights/health-score-card.tsx
@@ -42,6 +42,7 @@ import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { SCORE_PILLAR_LABEL_KEYS } from "@/lib/score-config/labels";
import { cn } from "@/lib/utils";
import { InlineAction } from "@/components/ui/inline-action";
+import { ScorePill } from "@/components/ui/score-pill";
/**
* The Health Score panel: the hero band's right-hand column.
@@ -434,12 +435,13 @@ export function HealthScoreCard({
{report.delta != null && report.delta > 0 ? (
-
+{report.delta}
-
+
) : null}
{/* v1.42 — the way to the score's course over time. */}
diff --git a/src/components/insights/mood/mood-better-days.tsx b/src/components/insights/mood/mood-better-days.tsx
index ab24868a9..18859ad53 100644
--- a/src/components/insights/mood/mood-better-days.tsx
+++ b/src/components/insights/mood/mood-better-days.tsx
@@ -4,8 +4,8 @@ import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
-import { cn } from "@/lib/utils";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
+import { CONFIDENCE_TONE, ScorePill } from "@/components/ui/score-pill";
/**
* v1.11.5 — "What's associated with your better days" board.
@@ -54,16 +54,6 @@ const CONFIDENCE_KEY: Record = {
high: "insights.mood.influence.confidenceHigh",
};
-// Confidence chips carry COLORED TEXT, so they ride the semantic feedback
-// tokens (`--info` / `--success`) — these carry the Alucard light-mode
-// overrides that clear AA on the white card. Raw `--dracula-*` stays bright
-// green/cyan in light mode and fails AA for text.
-const CONFIDENCE_CLASS: Record = {
- low: "bg-secondary text-muted-foreground",
- medium: "bg-info/15 text-info",
- high: "bg-success/15 text-success",
-};
-
export function MoodBetterDays({
factors,
}: {
@@ -146,14 +136,9 @@ export function MoodBetterDays({
>
{effectText}
-
+
{t(CONFIDENCE_KEY[factor.confidence])}
-
+
);
})}
diff --git a/src/components/insights/mood/mood-tag-influence.tsx b/src/components/insights/mood/mood-tag-influence.tsx
index 2a2893733..c0946273d 100644
--- a/src/components/insights/mood/mood-tag-influence.tsx
+++ b/src/components/insights/mood/mood-tag-influence.tsx
@@ -2,7 +2,7 @@
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
-import { cn } from "@/lib/utils";
+import { CONFIDENCE_TONE, ScorePill } from "@/components/ui/score-pill";
/**
* v1.11.5 — tag "Influence on Mood".
@@ -44,16 +44,6 @@ const CONFIDENCE_KEY: Record = {
high: "insights.mood.influence.confidenceHigh",
};
-// Confidence chips carry COLORED TEXT, so they ride the semantic feedback
-// tokens (`--info` / `--success`) — these carry the Alucard light-mode
-// overrides that clear AA on the white card. Raw `--dracula-*` stays bright
-// green/cyan in light mode and fails AA for text.
-const CONFIDENCE_CLASS: Record = {
- low: "bg-secondary text-muted-foreground",
- medium: "bg-info/15 text-info",
- high: "bg-success/15 text-success",
-};
-
// Semantic feedback tokens (not raw `--dracula-*`): the delta read-out is
// COLORED TEXT, so it needs the Alucard light-mode override to clear AA.
function deltaColor(delta: number): string {
@@ -100,14 +90,9 @@ export function MoodTagInfluence({ rows }: { rows: MoodTagInfluenceRow[] }) {
>
{deltaText}
-
+
{t(CONFIDENCE_KEY[row.confidence])}
-
+
{t("insights.mood.influence.detail", {
diff --git a/src/components/insights/personal-record-badge.tsx b/src/components/insights/personal-record-badge.tsx
index 1e0c5599d..dc5fc50b5 100644
--- a/src/components/insights/personal-record-badge.tsx
+++ b/src/components/insights/personal-record-badge.tsx
@@ -33,6 +33,7 @@ import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { MeasurementType } from "@/generated/prisma/client";
import { cn } from "@/lib/utils";
+import { ScorePill } from "@/components/ui/score-pill";
interface PersonalRecordRow {
id: string;
@@ -122,22 +123,16 @@ export function PersonalRecordBadge({
if (!fresh) return null;
const pill = (
-
{t("insights.personalRecord.badge")}
-
+
);
if (!withTooltip) return pill;
diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx
index daaf901b2..a5dd17d26 100644
--- a/src/components/insights/trend-annotation.tsx
+++ b/src/components/insights/trend-annotation.tsx
@@ -3,7 +3,6 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
-import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
@@ -17,8 +16,7 @@ import {
TREND_SLOT_DESCRIPTOR_META,
type TrendDescriptorPoint,
} from "@/lib/insights/trend-descriptor";
-import { cn } from "@/lib/utils";
-import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
+import { CONFIDENCE_TONE, ScorePill } from "@/components/ui/score-pill";
import { apiGet } from "@/lib/api/api-fetch";
import { useUnitDisplay } from "@/hooks/use-unit-display";
@@ -192,13 +190,12 @@ export function TrendAnnotation({
text={stripChartTokens(annotation)}
>
{confidence ? (
-
{t(CONFIDENCE_LABEL_KEY[confidence])}
-
+
) : null}
);
diff --git a/src/components/labs/reference-range-badge.tsx b/src/components/labs/reference-range-badge.tsx
index 4e59c1a8a..750fd6580 100644
--- a/src/components/labs/reference-range-badge.tsx
+++ b/src/components/labs/reference-range-badge.tsx
@@ -2,7 +2,7 @@
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
-import { Badge } from "@/components/ui/badge";
+import { ScorePill } from "@/components/ui/score-pill";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ReferenceRangeStatus } from "@/lib/validations/labs";
@@ -39,21 +39,16 @@ export function ReferenceRangeBadge({
if (status === "unknown") return null;
- // Compact: drop the icon, shrink the text and padding so the badge reads as a
- // subtle qualifier of the heading rather than a second focal point.
- const compactClass = compact
- ? "h-auto gap-0.5 px-1.5 py-0 text-xs font-normal [&>svg]:size-2.5"
- : "";
+ // Compact: a quieter, smaller pill beside the analyte heading, so it reads
+ // as a qualifier of the heading rather than a second focal point.
+ const compactClass = compact ? "gap-0.5 px-1.5 py-0 font-normal" : "";
if (status === "in-range") {
return (
-
+
{compact ? null : }
{t("labs.range.inRange")}
-
+
);
}
@@ -62,14 +57,13 @@ export function ReferenceRangeBadge({
const Icon = status === "below" ? ArrowDown : ArrowUp;
const label =
status === "below" ? t("labs.range.below") : t("labs.range.above");
- const toneClass =
- status === "below"
- ? "border-info/30 bg-info/10 text-info"
- : "border-warning/30 bg-warning/10 text-warning";
return (
-
+
{label}
-
+
);
}
diff --git a/src/components/targets/target-status-pill.tsx b/src/components/targets/target-status-pill.tsx
index 394d5be78..7446f085d 100644
--- a/src/components/targets/target-status-pill.tsx
+++ b/src/components/targets/target-status-pill.tsx
@@ -6,8 +6,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
+import { ScorePill } from "@/components/ui/score-pill";
/**
* v1.8.5 — `` extracted from `target-card.tsx`.
@@ -153,7 +153,8 @@ export function statusGroupForCategory(
return "near";
}
-// Coloured text on a 10% same-hue tint with a 30% ring. The tint and
+// Coloured text on a 10% same-hue tint, in the `ScorePill` anatomy. The
+// tone classes stay this pill's own, on purpose: the tint and
// text share one theme-aware token per tone (`--status-pill-{in,near,
// out}` in globals.css) so the pill clears WCAG AA (4.5:1) on both the
// Targets card (`bg-card`) and the W5 insights reference panel
@@ -171,9 +172,9 @@ export function statusGroupForCategory(
// that parses cleanly. See the Tailwind v4 upgrade guide for the
// migration from bracket-form to paren-form for CSS-variable values.
export const STATUS_PILL_STYLES: Record<"in" | "near" | "out", string> = {
- in: "bg-(--status-pill-in)/10 text-(--status-pill-in) ring-(--status-pill-in)/30",
- near: "bg-(--status-pill-near)/10 text-(--status-pill-near) ring-(--status-pill-near)/30",
- out: "bg-(--status-pill-out)/10 text-(--status-pill-out) ring-(--status-pill-out)/30",
+ in: "bg-(--status-pill-in)/10 text-(--status-pill-in)",
+ near: "bg-(--status-pill-near)/10 text-(--status-pill-near)",
+ out: "bg-(--status-pill-out)/10 text-(--status-pill-out)",
};
export interface TargetStatusPillProps {
@@ -204,16 +205,13 @@ export function TargetStatusPill({
-
{statusLabel}
-
+
{range && (
diff --git a/src/components/ui/__tests__/score-pill.test.tsx b/src/components/ui/__tests__/score-pill.test.tsx
new file mode 100644
index 000000000..402a06172
--- /dev/null
+++ b/src/components/ui/__tests__/score-pill.test.tsx
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { CONFIDENCE_TONE, ScorePill } from "../score-pill";
+
+describe("", () => {
+ it("paints the pill anatomy with the tone's soft wash and no border", () => {
+ const html = renderToStaticMarkup(
+ Medium,
+ );
+ expect(html).toContain("rounded-full");
+ expect(html).toContain("px-2 py-0.5 text-xs font-medium");
+ expect(html).toContain("bg-info/15 text-info");
+ expect(html).toContain('data-tone="info"');
+ expect(html).not.toMatch(/\bborder\b/);
+ });
+
+ it("defaults to the neutral tone", () => {
+ const html = renderToStaticMarkup(Low);
+ expect(html).toContain("bg-secondary text-muted-foreground");
+ expect(html).toContain('data-tone="neutral"');
+ });
+
+ it("sets figures in tabular numerals when numeric", () => {
+ const html = renderToStaticMarkup(
+
+ +4
+ ,
+ );
+ expect(html).toContain("tabular-nums");
+ expect(html).toContain("font-semibold");
+ });
+
+ it("lets a call site keep its own slot and override a tone token", () => {
+ const html = renderToStaticMarkup(
+
+ Optimal
+ ,
+ );
+ expect(html).toContain('data-slot="target-status-pill"');
+ expect(html).toContain("bg-(--status-pill-in)/10");
+ expect(html).not.toContain("bg-secondary");
+ });
+
+ it("reads both confidence scales as the same three steps", () => {
+ expect(CONFIDENCE_TONE.medium).toBe(CONFIDENCE_TONE.moderate);
+ expect(CONFIDENCE_TONE.high).toBe("success");
+ expect(CONFIDENCE_TONE.low).toBe("neutral");
+ });
+});
diff --git a/src/components/ui/score-pill.tsx b/src/components/ui/score-pill.tsx
new file mode 100644
index 000000000..c503c561d
--- /dev/null
+++ b/src/components/ui/score-pill.tsx
@@ -0,0 +1,76 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * The small rounded chip that states a grade beside a reading: a confidence
+ * level, a score gain, a band ("Optimal", "Hypertension grade 1"), a lab
+ * value below or above its range.
+ *
+ * One anatomy (`rounded-full px-2 py-0.5 text-xs font-medium`, a soft wash of
+ * the tone, no border) and one tone vocabulary, where there used to be two
+ * looks for the same meaning: the crosstab chips washed at /15 without a
+ * border, the correlation and trend badges washed at /10 with a /40 border,
+ * and "medium confidence" was blue on one card and amber on the next.
+ *
+ * The tone carries meaning, never decoration (UI standards §3):
+ * `success` / `info` / `warning` / `destructive` for a verdict, `neutral`
+ * for a grade that says nothing good or bad (low confidence).
+ *
+ * Not for a neutral label (that is `TagChip`) and not for the state of a
+ * connection or an account (that is `Badge`).
+ */
+const scorePillVariants = cva(
+ "inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap [&>svg]:size-3 [&>svg]:shrink-0",
+ {
+ variants: {
+ tone: {
+ success: "bg-success/15 text-success",
+ info: "bg-info/15 text-info",
+ warning: "bg-warning/15 text-warning",
+ destructive: "bg-destructive/15 text-destructive",
+ neutral: "bg-secondary text-muted-foreground",
+ },
+ numeric: {
+ true: "font-semibold tabular-nums",
+ false: "",
+ },
+ },
+ defaultVariants: { tone: "neutral", numeric: false },
+ },
+);
+
+export type ScorePillTone = NonNullable<
+ VariantProps["tone"]
+>;
+
+/**
+ * The tone of a confidence level. Two scales reach the UI — the crosstabs say
+ * `medium`, the correlation and trend payloads say `moderate` — and both mean
+ * the same middle step.
+ */
+export const CONFIDENCE_TONE = {
+ low: "neutral",
+ medium: "info",
+ moderate: "info",
+ high: "success",
+} as const satisfies Record;
+
+function ScorePill({
+ className,
+ tone,
+ numeric,
+ ...props
+}: React.ComponentProps<"span"> & VariantProps) {
+ return (
+
+ );
+}
+
+export { ScorePill, scorePillVariants };