mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge pull request #530 from MBombeck/release/v1.30.6
release/v1.30.6 — accessibility pass
This commit is contained in:
35 files changed
+221
-131
No files matched your search
@@ -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,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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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],
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user