diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index b09155700..1e7c79c55 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -108,11 +108,7 @@ export function CycleRingTile({ className }: { className?: string }) { className, )} > - +
{encounterKindText(t, encounter.kind as EncounterKind)} diff --git a/src/components/insights/__tests__/tile-header-wrap.test.tsx b/src/components/insights/__tests__/tile-header-wrap.test.tsx new file mode 100644 index 000000000..e578f4879 --- /dev/null +++ b/src/components/insights/__tests__/tile-header-wrap.test.tsx @@ -0,0 +1,99 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Heart } from "lucide-react"; + +import { + stripComments, + walkSourceFiles, +} from "@/__tests__/helpers/source-files"; + +import { TileHeader } from "../tile-header"; + +/** + * A tile title names the figure under it, so it wraps and never truncates. + * Seven callers passed `titleClassName="truncate"` and German compounds + * ("Erholungs-Score", "Herzfrequenzvariabilität") ended in an ellipsis on a + * narrow tile. The class override is gone; the primitive owns the wrap. + */ +function titleClasses(html: string): string { + const match = html.match( + /data-slot="card-title"[^>]*class="([^"]*)"|class="([^"]*)"[^>]*data-slot="card-title"/, + ); + expect(match, "card title not rendered").not.toBeNull(); + return match![1] ?? match![2]; +} + +describe(" title", () => { + it.each([ + ["default", undefined], + ["h2 heading", "h2" as const], + ])("wraps at a syllable as a %s", (_name, titleAs) => { + const html = renderToStaticMarkup( + , + ); + const classes = titleClasses(html); + expect(classes).toContain("min-w-0"); + expect(classes).toContain("break-words"); + expect(classes).toContain("hyphens-auto"); + expect(classes).not.toContain("truncate"); + expect(classes).not.toContain("whitespace-nowrap"); + }); + + it("keeps the compact variant on the same contract", () => { + const html = renderToStaticMarkup( + , + ); + const classes = titleClasses(html); + expect(classes).toContain("text-sm"); + expect(classes).toContain("hyphens-auto"); + expect(classes).not.toContain("truncate"); + }); +}); + +describe("no card title is cut", () => { + // A `CardTitle` or a tile `TileHeader` that truncates hides the one word + // that says what the card is. `truncate` stays legal on rows and meta + // lines that carry their full text in `title`; never on a card title. + const SRC = join(process.cwd(), "src"); + const TITLE_TAG = /<(CardTitle|TileHeader)\b[^>]*?>/g; + const CUT = /\b(truncate|whitespace-nowrap|titleClassName)\b/; + + const offenders: string[] = []; + let tags = 0; + for (const root of ["components", "app"]) { + for (const rel of walkSourceFiles(join(SRC, root), { + floor: root === "app" ? 100 : 500, + extensions: [".tsx"], + })) { + if (rel.includes("__tests__/")) continue; + const source = stripComments(readFileSync(join(SRC, root, rel), "utf8")); + for (const match of source.matchAll(TITLE_TAG)) { + tags += 1; + if (CUT.test(match[0])) offenders.push(`${root}/${rel}: ${match[1]}`); + } + } + } + + it("reads the title tags in the tree", () => { + expect(tags).toBeGreaterThan(60); + }); + + it("finds none that cut", () => { + expect(offenders).toEqual([]); + }); + + it("would catch one", () => { + const planted = `x`; + const found = [...planted.matchAll(TITLE_TAG)].filter((m) => + CUT.test(m[0]), + ); + expect(found).toHaveLength(1); + }); +}); diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index 9adccc5e5..db4e5e6e8 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -164,7 +164,6 @@ export function SparklineDeltaTile({ title={label} titleAs="h2" className="min-w-0" - titleClassName="min-w-0 truncate whitespace-nowrap" right={ provenance ? ( - +

{message}

diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index 71e76e550..e198469e5 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -154,7 +154,7 @@ function RingTile({ animate && "wellness-tile-rise", )} > - +
{/* The ring keeps just the number centred; the metric name lives in the header above. The per-metric `hue` gradient leans the arc diff --git a/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx index 39da9a327..ca7ca2323 100644 --- a/src/components/insights/device-score-tile.tsx +++ b/src/components/insights/device-score-tile.tsx @@ -118,7 +118,6 @@ export function DeviceScoreTile({ icon={Icon} title={title} className="min-w-0" - titleClassName="min-w-0 truncate" right={ latest != null ? ( {title} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 60f98110b..e64293365 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -63,7 +63,10 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return (
);