Merge pull request #530 from MBombeck/release/v1.30.6

release/v1.30.6 — accessibility pass
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-18 10:46:19 +02:00
commit 389bdb5d5d
35 files changed
+221 -131

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
## [Unreleased]
## [1.30.6] — 2026-07-18
Accessibility pass.
- **Heatmaps read to a screen reader.** The mood and medication-compliance grids now expose each recorded day — its date and value — through a hidden day list, so the per-day figures are no longer reachable by pointer only. The chart looks exactly the same.
- **Small labels meet the minimum size.** Muted meta text across settings, admin, the coach panel, and the insights cards is lifted off the sub-`text-xs` sizes; tabular figures and chart chrome are unchanged.
- **A couple of section headings.** Two metric-page sections became proper headings, so heading navigation lands where it should.
No migration. No breaking changes.
## [1.30.5] — 2026-07-18
Making a shipped feature findable, and a sync-status fix.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.5
version: 1.30.6
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.5",
"version": "1.30.6",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.5";
/* @sw-version-fallback */ "v1.30.6";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -153,14 +153,14 @@ describe("ApiTokenOverviewSection — responsive", () => {
const html = render();
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
expect(mobileMatch).not.toBeNull();
// The Last-used + Created lines use <p class="text-[11px]…"> — no
// whitespace-nowrap so a long German date+time can flow onto a
// The Last-used + Created lines are muted <p class="…text-xs"> meta —
// no whitespace-nowrap so a long German date+time can flow onto a
// second line within the card. The shadcn `<Badge>` primitive
// does carry `whitespace-nowrap`, which is fine for the short
// permission strings we render — those are scoped to a flex-wrap
// container that line-breaks at the badge boundary.
expect(mobileMatch![1]).toMatch(
/<p[^>]*text-\[11px\][^>]*>(?![^<]*whitespace-nowrap)/,
/<p[^>]*text-muted-foreground[^>]*text-xs[^>]*>(?![^<]*whitespace-nowrap)/,
);
});
});
@@ -328,13 +328,13 @@ export function ApiTokenOverviewSection() {
</Badge>
))}
</div>
<p className="text-muted-foreground mt-2 text-[11px]">
<p className="text-muted-foreground mt-2 text-xs">
{t("admin.tokenLastUsed")}:{" "}
{token.lastUsedAt
? formatDateTime(token.lastUsedAt)
: t("admin.tokenNeverUsed")}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("admin.tokenCreated")}: {formatDate(token.createdAt)}
</p>
</li>
+2 -2
View File
@@ -578,11 +578,11 @@ export function BackupsSection() {
<li>
<div className="flex items-center gap-2">
<span className="truncate font-medium">{row.username}</span>
<Badge variant="secondary" className="text-[10px]">
<Badge variant="secondary" className="text-xs">
{formatBackupType(row.type, t)}
</Badge>
</div>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.colSize")}:{" "}
{formatBytes(row.sizeBytes, fmt)} ·{" "}
{fmt.dateTime(row.createdAt)}
@@ -411,7 +411,7 @@ export function UserManagementSection() {
variant={
u.role === "ADMIN" ? "default" : "secondary"
}
className="text-[10px]"
className="text-xs"
>
{u.role}
</Badge>
@@ -419,7 +419,7 @@ export function UserManagementSection() {
<p className="text-muted-foreground truncate text-xs">
{u.email || "—"}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("admin.userPasskeys")}: {u.passkeyCount} ·{" "}
{formatDate(u.createdAt)}
</p>
@@ -5,17 +5,29 @@ import { I18nProvider } from "@/lib/i18n/context";
import { ComplianceHeatmap } from "../compliance-heatmap";
/**
* 2026-07-17 a11y audit (M2) — medication-compliance heatmap day cells
* were pointer-only SVG `<rect>`s: no `tabIndex`, no per-day `aria-label`.
* This pins the keyboard-reachability fix on top of the pre-existing
* aggregate `role="img"` summary on the SVG.
* 2026-07-17 a11y audit (M2) — medication-compliance heatmap day cells are
* pointer-only SVG `<rect>`s. The keyboard/screen-reader path to the
* per-day values is a visually-hidden day list beside the grid, not
* focusable rects (a cell subtree nested under the SVG's `role="img"` is
* pruned from the a11y tree, and per-cell tab stops would flood the
* keyboard order). This pins that list plus the aggregate `role="img"`
* summary, and that the rects stay a pure pointer affordance.
*/
// The grid window is the last `days` days ending today, so seed relative
// dates (UTC-sliced, matching the component) rather than fixed ones that
// would slide out of the window over time.
const dayKey = (offset: number) =>
new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10);
const D0 = dayKey(0);
const D1 = dayKey(1);
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ComplianceHeatmap
dailyCompliance={{
"2026-01-05": { expected: 2, taken: 2, skipped: 0 },
[D0]: { expected: 2, taken: 2, skipped: 0 },
[D1]: { expected: 2, taken: 1, skipped: 1 },
}}
days={14}
/>
@@ -23,20 +35,33 @@ function render() {
);
}
describe("<ComplianceHeatmap> — keyboard-reachable day cells", () => {
describe("<ComplianceHeatmap> — keyboard-reachable day values", () => {
it("keeps the aggregate role=img summary on the SVG", () => {
const html = render();
expect(html).toMatch(/<svg[^>]*role="img"[^>]*aria-label="[^"]+"/);
});
it("makes every day cell focusable with its own aria-label", () => {
it("keeps the day cells a pure pointer affordance (no tab stops)", () => {
const html = render();
const rectMatches = html.match(/<rect\b[^>]*>/g) ?? [];
expect(rectMatches.length).toBeGreaterThan(0);
for (const rect of rectMatches) {
expect(rect).toContain('tabindex="0"');
expect(rect).toContain('role="img"');
expect(rect).toMatch(/aria-label="[^"]+"/);
expect(rect).not.toContain("tabindex");
}
});
it("exposes each active day through a visually-hidden day list", () => {
const html = render();
// The sr-only list carries one entry per day that had a scheduled or
// taken dose — here the two seeded days.
const listMatch = html.match(
/<ul[^>]*data-slot="compliance-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/,
);
expect(listMatch).not.toBeNull();
const items = listMatch![1].match(/<li[^>]*>/g) ?? [];
expect(items.length).toBe(2);
// Each entry pairs the day with its taken/expected + rate string.
expect(listMatch![1]).toContain("2/2 (100%)");
expect(listMatch![1]).toContain("1/2 (50%)");
});
});
@@ -5,17 +5,29 @@ import { I18nProvider } from "@/lib/i18n/context";
import { MoodHeatmap } from "../mood-heatmap";
/**
* 2026-07-17 a11y audit (M2) — mood heatmap day cells were pointer-only
* SVG `<rect>`s: no `tabIndex`, no per-day `aria-label`. This pins the
* keyboard-reachability fix (each cell focusable + self-describing) on
* top of the pre-existing aggregate `role="img"` summary on the SVG.
* 2026-07-17 a11y audit (M2) — mood heatmap day cells are pointer-only SVG
* `<rect>`s. The keyboard/screen-reader path to the per-day values is a
* visually-hidden day list beside the grid, not focusable rects (a cell
* subtree nested under the SVG's `role="img"` is pruned from the a11y
* tree, and per-cell tab stops would flood the keyboard order). This pins
* that list plus the aggregate `role="img"` summary, and that the rects
* stay a pure pointer affordance.
*/
// The grid window is the last `days` days ending today, so seed relative
// dates (UTC-sliced, matching the component) rather than fixed ones that
// would slide out of the window over time.
const dayKey = (offset: number) =>
new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10);
const D0 = dayKey(0);
const D1 = dayKey(1);
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodHeatmap
cells={{
"2026-01-05": { date: "2026-01-05", score: 4.2, samples: 2 },
[D0]: { date: D0, score: 4.2, samples: 2 },
[D1]: { date: D1, score: 2.0, samples: 1 },
}}
days={14}
/>
@@ -23,20 +35,31 @@ function render() {
);
}
describe("<MoodHeatmap> — keyboard-reachable day cells", () => {
describe("<MoodHeatmap> — keyboard-reachable day values", () => {
it("keeps the aggregate role=img summary on the SVG", () => {
const html = render();
expect(html).toMatch(/<svg[^>]*role="img"[^>]*aria-label="[^"]+"/);
});
it("makes every day cell focusable with its own aria-label", () => {
it("keeps the day cells a pure pointer affordance (no tab stops)", () => {
const html = render();
const rectMatches = html.match(/<rect\b[^>]*>/g) ?? [];
expect(rectMatches.length).toBeGreaterThan(0);
for (const rect of rectMatches) {
expect(rect).toContain('tabindex="0"');
expect(rect).toContain('role="img"');
expect(rect).toMatch(/aria-label="[^"]+"/);
expect(rect).not.toContain("tabindex");
}
});
it("exposes each logged day through a visually-hidden day list", () => {
const html = render();
const listMatch = html.match(
/<ul[^>]*data-slot="mood-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/,
);
expect(listMatch).not.toBeNull();
const items = listMatch![1].match(/<li[^>]*>/g) ?? [];
// One entry per logged day — the two seeded days, not every cell.
expect(items.length).toBe(2);
expect(listMatch![1]).toContain("4.2");
expect(listMatch![1]).toContain("2.0");
});
});
@@ -248,7 +248,7 @@ export function ChartOverlayControlsBody({
prefs.comparisonBaseline === value ? "default" : "outline"
}
size="sm"
className={`min-h-11 px-2 text-[11px] sm:min-h-9 ${isGreyed ? "opacity-50" : ""}`.trim()}
className={`min-h-11 px-2 text-xs sm:min-h-9 ${isGreyed ? "opacity-50" : ""}`.trim()}
onClick={() => setComparisonBaseline(value)}
aria-pressed={prefs.comparisonBaseline === value}
aria-disabled={isGreyed || undefined}
@@ -263,7 +263,7 @@ export function ChartOverlayControlsBody({
</div>
{comparisonGreyOut && (
<p
className="text-muted-foreground text-[10px] leading-snug"
className="text-muted-foreground text-xs leading-snug"
data-slot="chart-overlay-comparison-unavailable-hint"
>
{disabledTooltip}
+46 -36
View File
@@ -269,6 +269,32 @@ export function ComplianceHeatmap({
const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP;
const svgHeight = headerHeight + 7 * cellSize + 6 * GAP;
// Per-day text alternative, shared by the pointer tooltip and the
// visually-hidden day list below the grid (M2). One source of truth so
// the sr-only list never drifts from what a pointer user reads.
const describeCell = (cell: (typeof cells)[number]): string => {
const rate =
cell.data.expected > 0
? Math.min(
100,
Math.round((cell.data.taken / cell.data.expected) * 100),
)
: 0;
const hasTimingData =
cell.data.onTime !== undefined ||
cell.data.late !== undefined ||
cell.data.veryLate !== undefined;
const timingInfo = hasTimingData
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
: "";
return `${formatDay(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
};
// Only days that carried a scheduled or taken dose go into the sr-only
// list; empty days are covered by the aggregate summary on the SVG.
const activeCells = cells.filter(
(cell) => cell.data.expected > 0 || cell.data.taken > 0,
);
return (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
{/* v1.4.27 MB7 / CF-10 — `overflow-x-auto` on `<sm` so the heatmap
@@ -336,23 +362,7 @@ export function ComplianceHeatmap({
cell) to move/clear. `pointerType === "touch"` discriminates
so a hover dismiss never wipes a pinned tooltip. */}
{cells.map((cell) => {
const buildText = (): string => {
const rate =
cell.data.expected > 0
? Math.min(
100,
Math.round((cell.data.taken / cell.data.expected) * 100),
)
: 0;
const hasTimingData =
cell.data.onTime !== undefined ||
cell.data.late !== undefined ||
cell.data.veryLate !== undefined;
const timingInfo = hasTimingData
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
: "";
return `${formatDay(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
};
const buildText = (): string => describeCell(cell);
return (
<rect
key={cell.dateKey}
@@ -362,14 +372,13 @@ export function ComplianceHeatmap({
height={cellSize}
rx={2}
fill={cell.color}
className="cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--ring)]"
// 2026-07-17 a11y audit (M2) — the aggregate `role="img"` on
// the whole SVG summarises the window; each cell also gets
// its own per-day label so a screen-reader / keyboard user
// can reach the same value the pointer-only tooltip shows.
tabIndex={0}
role="img"
aria-label={buildText()}
className="cursor-pointer"
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the visually-hidden day list below
// the grid, not through the rects: a cell subtree nested
// under the SVG's `role="img"` is pruned from the a11y tree,
// and per-cell tab stops would flood the keyboard order on a
// year-long grid. The rects stay a pure pointer affordance.
onPointerEnter={(e) => {
// Touch enter fires synthetically immediately before
// `pointerdown`; skip it so the pinned tooltip below
@@ -394,23 +403,24 @@ export function ComplianceHeatmap({
pinned: true,
});
}}
onFocus={(e) => {
const box = e.currentTarget.getBoundingClientRect();
setTooltip({
x: box.left + box.width / 2,
y: box.top,
text: buildText(),
});
}}
onBlur={() => {
setTooltip((prev) => (prev?.pinned ? prev : null));
}}
/>
);
})}
</svg>
</div>
{/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG
carries an aggregate `role="img"` summary; this list is the
keyboard/screen-reader path to the same granular values the pointer
tooltip shows, without adding a tab stop per cell. */}
{activeCells.length > 0 && (
<ul className="sr-only" data-slot="compliance-heatmap-day-list">
{activeCells.map((cell) => (
<li key={cell.dateKey}>{describeCell(cell)}</li>
))}
</ul>
)}
{/* Tooltip */}
{tooltip && (
<div
+36 -29
View File
@@ -226,6 +226,22 @@ export function MoodHeatmap({
const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP;
const svgHeight = headerHeight + 7 * cellSize + 6 * GAP;
// Per-day text alternative, shared by the pointer tooltip and the
// visually-hidden day list below the grid (M2). One source of truth so
// the sr-only list never drifts from what a pointer user reads.
const describeCell = (cell: (typeof cells)[number]): string => {
if (!cell.cell) {
return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`;
}
const labelKey = moodLabelKeyForScore(Math.round(cell.cell.score));
const moodLabel = labelKey ? t(labelKey) : "";
return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
};
// Only the logged days go into the sr-only list; the empty-tinted cells
// are already covered by the aggregate summary on the SVG, so a rotor
// user hears the days that carry a value rather than a wall of "no entry".
const loggedCells = cells.filter((cell) => cell.cell !== null);
return (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
<div
@@ -278,16 +294,7 @@ export function MoodHeatmap({
)}
{cells.map((cell) => {
const buildText = (): string => {
if (!cell.cell) {
return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`;
}
const labelKey = moodLabelKeyForScore(
Math.round(cell.cell.score),
);
const moodLabel = labelKey ? t(labelKey) : "";
return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
};
const buildText = (): string => describeCell(cell);
return (
<rect
key={cell.dateKey}
@@ -301,14 +308,13 @@ export function MoodHeatmap({
// mood band reads clearly; only the no-entry cells stay the
// quiet `--secondary` empty-state tint.
fillOpacity={1}
className="cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--ring)]"
// 2026-07-17 a11y audit (M2) — the aggregate `role="img"` on
// the whole SVG summarises the window; each cell also gets
// its own per-day label so a screen-reader / keyboard user
// can reach the same value the pointer-only tooltip shows.
tabIndex={0}
role="img"
aria-label={buildText()}
className="cursor-pointer"
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the visually-hidden day list below
// the grid, not through the rects: a cell subtree nested
// under the SVG's `role="img"` is pruned from the a11y tree,
// and per-cell tab stops would flood the keyboard order on a
// year-long grid. The rects stay a pure pointer affordance.
onPointerEnter={(e) => {
if (e.pointerType === "touch") return;
setTooltip({
@@ -330,23 +336,24 @@ export function MoodHeatmap({
pinned: true,
});
}}
onFocus={(e) => {
const box = e.currentTarget.getBoundingClientRect();
setTooltip({
x: box.left + box.width / 2,
y: box.top,
text: buildText(),
});
}}
onBlur={() => {
setTooltip((prev) => (prev?.pinned ? prev : null));
}}
/>
);
})}
</svg>
</div>
{/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG
carries an aggregate `role="img"` summary; this list is the
keyboard/screen-reader path to the same granular values the pointer
tooltip shows, without adding a tab stop per cell. */}
{loggedCells.length > 0 && (
<ul className="sr-only" data-slot="mood-heatmap-day-list">
{loggedCells.map((cell) => (
<li key={cell.dateKey}>{describeCell(cell)}</li>
))}
</ul>
)}
{tooltip && (
<div
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
+1 -1
View File
@@ -360,7 +360,7 @@ function StatChip({
style={{ backgroundColor: hue }}
/>
<span className="flex flex-col">
<span className="text-muted-foreground text-[11px] leading-tight">
<span className="text-muted-foreground text-xs leading-tight">
{label}
</span>
<span className="text-foreground text-sm font-semibold tabular-nums">
@@ -187,7 +187,7 @@ export function CyclePhaseCrosstab({
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
@@ -43,4 +43,15 @@ describe("<DashboardHeader> — greeting", () => {
// The title itself stays.
expect(html).toContain("Dashboard");
});
// 2026-07-17 a11y audit (M4) — the dashboard is the app's landing surface
// and must expose a page-level `<h1>`. It comes from the shared
// `PageHeader` the header renders, so a screen-reader user navigating by
// heading lands on a real page anchor (not the Today hero's muted `<h2>`
// micro-label). No separate sr-only heading is added — that would double
// the `h1`.
it("renders a real page-level h1 with the dashboard title", () => {
const html = renderSSR(<DashboardHeader onQuickEntry={() => undefined} />);
expect(html).toMatch(/<h1[^>]*>Dashboard<\/h1>/);
});
});
@@ -1040,11 +1040,11 @@ export function CoachConversation({
<span className="truncate">{docScopeText}</span>
</span>
</div>
<p className="text-muted-foreground text-[11px] leading-snug">
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.coach.attach.fencedHint")}
</p>
{anyPendingUnindexed ? (
<p className="text-muted-foreground text-[11px] leading-snug">
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.coach.attach.indexingHint")}
</p>
) : null}
@@ -1143,9 +1143,7 @@ export function CoachConversation({
{renderDescription ? (
renderDescription(tagline)
) : (
<p className="text-muted-foreground truncate text-[11px]">
{tagline}
</p>
<p className="text-muted-foreground truncate text-xs">{tagline}</p>
)}
</div>
<Button
@@ -216,7 +216,7 @@ export function CoachDrawer({
</SheetTitle>
)}
renderDescription={(tagline) => (
<SheetDescription className="text-muted-foreground truncate text-[11px]">
<SheetDescription className="text-muted-foreground truncate text-xs">
{tagline}
</SheetDescription>
)}
@@ -133,7 +133,7 @@ export function GuidedQuestionBubble({
<>
<p
data-slot="coach-guided-progress"
className="text-primary mb-1 flex items-center gap-2 text-[10px] font-semibold tracking-wider uppercase"
className="text-primary mb-1 flex items-center gap-2 text-xs font-semibold tracking-wider uppercase"
>
{t("insights.coach.guided.progress", {
current: progress.current,
@@ -88,7 +88,7 @@ export function SourceChips({ provenance, className }: SourceChipsProps) {
className={cn(
"border-info/25 text-info/90",
"inline-flex items-center gap-1 rounded-full border bg-transparent",
"px-2 py-0.5 text-[11px] leading-none",
"px-2 py-0.5 text-xs leading-none",
)}
>
<Link2 className="h-2.5 w-2.5" aria-hidden="true" />
+1 -1
View File
@@ -76,7 +76,7 @@ function DraftPill({ ariaLabel }: { ariaLabel: string }) {
data-confidence-band="draft"
role="img"
aria-label={ariaLabel}
className="bg-destructive/10 text-destructive border-destructive/25 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase"
className="bg-destructive/10 text-destructive border-destructive/25 inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium tracking-wide uppercase"
>
{t("insights.recommendation.confidenceDraft")}
</span>
@@ -123,6 +123,9 @@ export function CoachReadStrip({
<TileHeader
icon={Sparkles}
title={t("insights.coach.readStrip.label")}
// L3 — top-level section on every metric sub-page, sibling of the
// already-`h2` stat strip directly under the page `h1`.
titleAs="h2"
/>
</CardHeader>
<CardContent>
@@ -316,6 +316,9 @@ function TargetReferencePanel({
<TileHeader
icon={Target}
title={t("insights.target.heading")}
// L3 — top-level section on every metric sub-page, sibling of the
// already-`h2` stat strip directly under the page `h1`.
titleAs="h2"
right={
target.classification ? (
<TargetStatusPill
@@ -147,7 +147,7 @@ export function MoodBetterDays({
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
CONFIDENCE_CLASS[factor.confidence],
)}
>
@@ -138,7 +138,7 @@ export function MoodFactorMetricCrosstab({
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
@@ -101,7 +101,7 @@ export function MoodTagInfluence({ rows }: { rows: MoodTagInfluenceRow[] }) {
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
@@ -137,7 +137,7 @@ export function MoodTagMetricCrosstab({
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
+1 -1
View File
@@ -194,7 +194,7 @@ export function TrendAnnotation({
<Badge
data-slot="trend-annotation-confidence"
variant="outline"
className={cn("text-[10px]", CONFIDENCE_BADGE_CLASS[confidence])}
className={cn("text-xs", CONFIDENCE_BADGE_CLASS[confidence])}
>
{t(CONFIDENCE_LABEL_KEY[confidence])}
</Badge>
@@ -201,7 +201,7 @@ export function ScheduleHistoryTimeline({
: t("medications.detail.zeitplan.history.noTimes")}
{revision.source === "MANUAL" && (
<span
className="border-primary/30 bg-primary/10 text-primary ml-2 inline-flex rounded-full border px-2 py-px align-middle text-[11px] font-medium"
className="border-primary/30 bg-primary/10 text-primary ml-2 inline-flex rounded-full border px-2 py-px align-middle text-xs font-medium"
data-slot="zeitplan-history-manual-chip"
>
{t("medications.detail.zeitplan.history.manualChip")}
@@ -492,7 +492,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
<span aria-hidden className="block text-sm">
{value}
</span>
<span className="block text-[10px] opacity-80">
<span className="block text-xs opacity-80">
{t(`medications.sideEffects.severity.${label}`)}
</span>
</button>
@@ -517,7 +517,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
// px-3 py-2 is too tall inside the side-effect modal.
className="px-2 py-1.5"
/>
<p className="text-muted-foreground text-right text-[10px]">
<p className="text-muted-foreground text-right text-xs">
{notes.length} / {NOTES_MAX}
</p>
</div>
+1 -1
View File
@@ -1107,7 +1107,7 @@ function MoodNoteText({
aria-expanded={expanded}
aria-controls={noteId}
data-testid="mood-note-toggle"
className="text-primary cursor-pointer text-[11px] font-medium"
className="text-primary cursor-pointer text-xs font-medium"
>
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
</button>
@@ -323,7 +323,7 @@ function SourceCardItem({
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold tracking-tight">{titleLabel}</h2>
{recommendedLabel ? (
<span className="bg-primary/10 text-primary mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase">
<span className="bg-primary/10 text-primary mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium tracking-wide uppercase">
{recommendedLabel}
</span>
) : null}
+3 -3
View File
@@ -119,7 +119,7 @@ function ApiEndpointsCard() {
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-[10px]">
<Badge variant="secondary" className="text-xs">
{endpoint.method}
</Badge>
<code className="font-mono break-all">{endpoint.path}</code>
@@ -457,11 +457,11 @@ function ApiTokensCard() {
{tok.name}
</p>
{isExpired ? (
<Badge variant="destructive" className="text-[10px]">
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-success/15 text-success text-[10px]">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.tokenActive")}
</Badge>
)}
+3 -3
View File
@@ -124,7 +124,7 @@ function McpConnectionsCard() {
<p className="min-w-0 flex-1 text-sm font-medium break-all">
{conn.clientName}
</p>
<Badge className="bg-success/15 text-success text-[10px]">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.mcp.connectionConnected")}
</Badge>
</div>
@@ -438,11 +438,11 @@ function McpTokensCard() {
{tok.name}
</p>
{isExpired ? (
<Badge variant="destructive" className="text-[10px]">
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-success/15 text-success text-[10px]">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.tokenActive")}
</Badge>
)}
+5 -5
View File
@@ -125,13 +125,13 @@ function ShareLinksCard() {
{link.label}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="bg-success/15 text-success text-[10px]">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.sharing.statusActive")}
</Badge>
{link.protected && (
<Badge
variant="outline"
className="gap-1 text-[10px]"
className="gap-1 text-xs"
data-testid="share-protected-badge"
>
<KeyRound className="h-2.5 w-2.5" />
@@ -141,7 +141,7 @@ function ShareLinksCard() {
{link.documentCount > 0 && (
<Badge
variant="outline"
className="gap-1 text-[10px]"
className="gap-1 text-xs"
data-testid="share-doc-count-badge"
aria-label={t("settings.sharing.documentCount", {
count: link.documentCount,
@@ -152,7 +152,7 @@ function ShareLinksCard() {
</Badge>
)}
{link.allowFhirApi && (
<Badge variant="outline" className="text-[10px]">
<Badge variant="outline" className="text-xs">
FHIR
</Badge>
)}
@@ -240,7 +240,7 @@ function ShareLinksCard() {
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<Badge variant="secondary" className="text-[10px]">
<Badge variant="secondary" className="text-xs">
{link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}