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 (
);