mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(ui): ScorePill for every grade chip
Confidence levels, the weekly score gain, target bands and lab range flags were drawn by hand in two looks: a /15 wash without border in the mood boards, a /10 wash with a /40 border on the correlation and trend cards, and medium confidence was blue on one card and amber on the next. They now render through one ScorePill (rounded, xs, soft wash, tone success / info / warning / destructive / neutral, optional tabular figures) with one confidence scale. The target band keeps its own AA-tuned tokens in the shared anatomy. A structural test refuses a new hand-rolled grade chip.
This commit is contained in:
1 parent
ab3f21c58c
commit
ee2bc8019c
14 files changed
+278
-131
No files matched your search
@@ -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<string, string> = {
|
||||
// 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<string>();
|
||||
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" }; <span className={cn("shrink-0 rounded-full px-2 py-0.5 text-xs font-medium bg-info/15 text-info")} />',
|
||||
},
|
||||
{
|
||||
path: "components/b.tsx",
|
||||
source:
|
||||
'<span className="bg-muted rounded-full px-2 py-0.5 text-xs" />',
|
||||
},
|
||||
]),
|
||||
).toEqual(["components/a.tsx"]);
|
||||
});
|
||||
|
||||
it("is used where the copies were", () => {
|
||||
const users = files.filter((f) => f.source.includes("<ScorePill"));
|
||||
expect(users.length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
@@ -164,7 +164,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
// behind the delete toast's Undo action.
|
||||
"toast.success": 3,
|
||||
},
|
||||
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
|
||||
// v1.36.x — two call sites, one decision: the intake toast routes through
|
||||
// `intakeToastOptions()`, which returns `undefined` in the caller's own
|
||||
// record, and sonner's one-argument form is what the unit tests assert. So
|
||||
@@ -214,25 +213,10 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/insights/coach-panel/suggested-action-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/confidence-badge.ts": { "text-success": 1 },
|
||||
"src/components/insights/daily-briefing.tsx": { "text-success": 1 },
|
||||
"src/components/insights/derived/band-tokens.ts": { "text-success": 1 },
|
||||
// The green `+N` chip beside the Health Score label. It reports a week that
|
||||
// went up, not an action that succeeded, and it wears the same band token
|
||||
// the score's own number does — there is no outcome here to route through
|
||||
// the outcome module.
|
||||
"src/components/insights/health-score-card.tsx": { "text-success": 1 },
|
||||
"src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 },
|
||||
"src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 },
|
||||
"src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 },
|
||||
"src/components/insights/mood/mood-factor-metric-crosstab.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/mood/mood-tag-influence.tsx": { "text-success": 1 },
|
||||
"src/components/insights/mood/mood-tag-metric-crosstab.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/personal-record-badge.tsx": { "text-success": 1 },
|
||||
"src/components/labs/biomarker-form.tsx": { "toast.success": 1 },
|
||||
"src/components/labs/biomarker-manager.tsx": { "toast.success": 2 },
|
||||
"src/components/labs/lab-biomarker-detail.tsx": { "toast.success": 1 },
|
||||
@@ -430,6 +414,11 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/settings/webhook-card.tsx": { "text-success": 1 },
|
||||
"src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 },
|
||||
"src/components/ui/password-strength.tsx": { "text-success": 2 },
|
||||
// The success tone of the grade chip (a high confidence, a score that went
|
||||
// up). It states a grade beside a reading, never that a write succeeded;
|
||||
// the crosstab, confidence and score chips that each carried their own copy
|
||||
// of this class now render through it.
|
||||
"src/components/ui/score-pill.tsx": { "text-success": 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -232,7 +232,7 @@
|
||||
/*
|
||||
* v1.28.17 — the sleep-stage IN_BED fill. Distinct from `--dracula-comment`
|
||||
* above (an orphaned token — no `@theme` utility was ever generated for
|
||||
* it, per the `confidence-badge.ts` note — and its `#9aa3b3` value never
|
||||
* it — and its `#9aa3b3` value never
|
||||
* matched the classic Dracula "Comment" blue-grey this chart actually
|
||||
* paints). Minted here instead of retuning the unrelated token, so this
|
||||
* default carries the exact hex the stacked bar hardcoded before
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* v1.4.20 — shared confidence-band classnames.
|
||||
*
|
||||
* Both `<CorrelationCard>` and `<TrendAnnotation>` 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<ConfidenceBand, string> = {
|
||||
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",
|
||||
};
|
||||
@@ -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" ? (
|
||||
<Badge
|
||||
<ScorePill
|
||||
data-slot="correlation-card-confidence"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"shrink-0 text-xs",
|
||||
CONFIDENCE_BADGE_CLASS[result.confidenceBand.label],
|
||||
)}
|
||||
tone={CONFIDENCE_TONE[result.confidenceBand.label]}
|
||||
>
|
||||
{t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])}
|
||||
</Badge>
|
||||
</ScorePill>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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({
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{report.delta != null && report.delta > 0 ? (
|
||||
<span
|
||||
<ScorePill
|
||||
tone="success"
|
||||
numeric
|
||||
data-slot="health-score-card-delta-chip"
|
||||
className="bg-success/15 text-success shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"
|
||||
>
|
||||
+{report.delta}
|
||||
</span>
|
||||
</ScorePill>
|
||||
) : null}
|
||||
{/* v1.42 — the way to the score's course over time. */}
|
||||
<InlineAction asChild tone="foreground">
|
||||
|
||||
@@ -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<MoodInfluenceConfidence, string> = {
|
||||
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<MoodInfluenceConfidence, string> = {
|
||||
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}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
CONFIDENCE_CLASS[factor.confidence],
|
||||
)}
|
||||
>
|
||||
<ScorePill tone={CONFIDENCE_TONE[factor.confidence]}>
|
||||
{t(CONFIDENCE_KEY[factor.confidence])}
|
||||
</span>
|
||||
</ScorePill>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<MoodInfluenceConfidence, string> = {
|
||||
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<MoodInfluenceConfidence, string> = {
|
||||
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}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
CONFIDENCE_CLASS[row.confidence],
|
||||
)}
|
||||
>
|
||||
<ScorePill tone={CONFIDENCE_TONE[row.confidence]}>
|
||||
{t(CONFIDENCE_KEY[row.confidence])}
|
||||
</span>
|
||||
</ScorePill>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.influence.detail", {
|
||||
|
||||
@@ -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 = (
|
||||
<span
|
||||
<ScorePill
|
||||
tone="success"
|
||||
numeric
|
||||
data-slot="insights-pr-badge"
|
||||
data-metric-type={metricType}
|
||||
className={cn(
|
||||
// `--success` resolves to the AA-tuned green per theme: a deep
|
||||
// forest green on the light card (#14720a) and Dracula's neon
|
||||
// green on the dark card (#50fa7b). Both clear 4.5:1 against
|
||||
// their card background — the previous `text-success` on
|
||||
// `bg-success/10` measured ~2.6:1 in dark mode.
|
||||
"border-success/40 bg-success/15 text-success text-2xs inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 leading-none font-semibold tracking-wide uppercase tabular-nums",
|
||||
className,
|
||||
)}
|
||||
className={cn("tracking-wide uppercase", className)}
|
||||
aria-label={t("insights.personalRecord.badge")}
|
||||
>
|
||||
{t("insights.personalRecord.badge")}
|
||||
</span>
|
||||
</ScorePill>
|
||||
);
|
||||
|
||||
if (!withTooltip) return pill;
|
||||
|
||||
@@ -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 ? (
|
||||
<Badge
|
||||
<ScorePill
|
||||
data-slot="trend-annotation-confidence"
|
||||
variant="outline"
|
||||
className={cn("text-xs", CONFIDENCE_BADGE_CLASS[confidence])}
|
||||
tone={CONFIDENCE_TONE[confidence]}
|
||||
>
|
||||
{t(CONFIDENCE_LABEL_KEY[confidence])}
|
||||
</Badge>
|
||||
</ScorePill>
|
||||
) : null}
|
||||
</TrendCaptionCard>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-muted-foreground", compactClass, className)}
|
||||
>
|
||||
<ScorePill tone="neutral" className={cn(compactClass, className)}>
|
||||
{compact ? null : <Minus aria-hidden />}
|
||||
{t("labs.range.inRange")}
|
||||
</Badge>
|
||||
</ScorePill>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Badge variant="outline" className={cn(toneClass, compactClass, className)}>
|
||||
<ScorePill
|
||||
tone={status === "below" ? "info" : "warning"}
|
||||
className={cn(compactClass, className)}
|
||||
>
|
||||
<Icon aria-hidden />
|
||||
{label}
|
||||
</Badge>
|
||||
</ScorePill>
|
||||
);
|
||||
}
|
||||
@@ -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 — `<TargetStatusPill>` 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({
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
<ScorePill
|
||||
data-slot="target-status-pill"
|
||||
data-status={statusGroup}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1",
|
||||
statusPillStyle,
|
||||
)}
|
||||
className={statusPillStyle}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</ScorePill>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{range && (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CONFIDENCE_TONE, ScorePill } from "../score-pill";
|
||||
|
||||
describe("<ScorePill>", () => {
|
||||
it("paints the pill anatomy with the tone's soft wash and no border", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ScorePill tone="info">Medium</ScorePill>,
|
||||
);
|
||||
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(<ScorePill>Low</ScorePill>);
|
||||
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(
|
||||
<ScorePill tone="success" numeric>
|
||||
+4
|
||||
</ScorePill>,
|
||||
);
|
||||
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(
|
||||
<ScorePill
|
||||
data-slot="target-status-pill"
|
||||
className="bg-(--status-pill-in)/10 text-(--status-pill-in)"
|
||||
>
|
||||
Optimal
|
||||
</ScorePill>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof scorePillVariants>["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<string, ScorePillTone>;
|
||||
|
||||
function ScorePill({
|
||||
className,
|
||||
tone,
|
||||
numeric,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof scorePillVariants>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="score-pill"
|
||||
data-tone={tone ?? "neutral"}
|
||||
className={cn(scorePillVariants({ tone, numeric }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScorePill, scorePillVariants };
|
||||
Reference in new issue
Block a user