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 };