diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0870ba3..b5d019022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index baf24f46d..ebfc5cc5d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index 1ee141df0..32c841c77 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index b79a2e67b..9cb1e51b4 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx index 16a1eeff5..1991a9957 100644 --- a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx +++ b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx @@ -153,14 +153,14 @@ describe("ApiTokenOverviewSection — responsive", () => { const html = render(); const mobileMatch = html.match(/]*md:hidden[^>]*>([\s\S]*?)<\/ul>/); expect(mobileMatch).not.toBeNull(); - // The Last-used + Created lines use

— no - // whitespace-nowrap so a long German date+time can flow onto a + // The Last-used + Created lines are muted

meta — + // no whitespace-nowrap so a long German date+time can flow onto a // second line within the card. The shadcn `` 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( - /]*text-\[11px\][^>]*>(?![^<]*whitespace-nowrap)/, + /]*text-muted-foreground[^>]*text-xs[^>]*>(?![^<]*whitespace-nowrap)/, ); }); }); diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 93044b04d..2e7c3d49a 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -328,13 +328,13 @@ export function ApiTokenOverviewSection() { ))} -

+

{t("admin.tokenLastUsed")}:{" "} {token.lastUsedAt ? formatDateTime(token.lastUsedAt) : t("admin.tokenNeverUsed")}

-

+

{t("admin.tokenCreated")}: {formatDate(token.createdAt)}

diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 5b73bbbdf..24bbfbf30 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -578,11 +578,11 @@ export function BackupsSection() {
  • {row.username} - + {formatBackupType(row.type, t)}
    -

    +

    {t("admin.section.backups.colSize")}:{" "} {formatBytes(row.sizeBytes, fmt)} ·{" "} {fmt.dateTime(row.createdAt)} diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index b73d63835..91a38518f 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -411,7 +411,7 @@ export function UserManagementSection() { variant={ u.role === "ADMIN" ? "default" : "secondary" } - className="text-[10px]" + className="text-xs" > {u.role} @@ -419,7 +419,7 @@ export function UserManagementSection() {

    {u.email || "—"}

    -

    +

    {t("admin.userPasskeys")}: {u.passkeyCount} ·{" "} {formatDate(u.createdAt)}

    diff --git a/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx index 5e5de3c15..28ea8a862 100644 --- a/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx +++ b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx @@ -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 ``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 ``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( @@ -23,20 +35,33 @@ function render() { ); } -describe(" — keyboard-reachable day cells", () => { +describe(" — keyboard-reachable day values", () => { it("keeps the aggregate role=img summary on the SVG", () => { const html = render(); expect(html).toMatch(/]*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(/]*>/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( + /]*data-slot="compliance-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/, + ); + expect(listMatch).not.toBeNull(); + const items = listMatch![1].match(/]*>/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%)"); + }); }); diff --git a/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx index 61db1fc72..938254e8c 100644 --- a/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx +++ b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx @@ -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 ``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 + * ``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( @@ -23,20 +35,31 @@ function render() { ); } -describe(" — keyboard-reachable day cells", () => { +describe(" — keyboard-reachable day values", () => { it("keeps the aggregate role=img summary on the SVG", () => { const html = render(); expect(html).toMatch(/]*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(/]*>/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( + /]*data-slot="mood-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/, + ); + expect(listMatch).not.toBeNull(); + const items = listMatch![1].match(/]*>/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"); + }); }); diff --git a/src/components/charts/chart-overlay-controls.tsx b/src/components/charts/chart-overlay-controls.tsx index e759a8009..93e8c19d4 100644 --- a/src/components/charts/chart-overlay-controls.tsx +++ b/src/components/charts/chart-overlay-controls.tsx @@ -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({ {comparisonGreyOut && (

    {disabledTooltip} diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index b4a506449..7eec7a959 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -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 (

    {/* v1.4.27 MB7 / CF-10 — `overflow-x-auto` on ` { - 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 ( { // 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)); - }} /> ); })}
    + {/* 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 && ( +
      + {activeCells.map((cell) => ( +
    • {describeCell(cell)}
    • + ))} +
    + )} + {/* Tooltip */} {tooltip && (
    { + 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 (
    { - 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 ( { 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)); - }} /> ); })}
    + {/* 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 && ( +
      + {loggedCells.map((cell) => ( +
    • {describeCell(cell)}
    • + ))} +
    + )} + {tooltip && (
    - + {label} diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index f2badb1aa..8e0f4d693 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -187,7 +187,7 @@ export function CyclePhaseCrosstab({ diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index 7746aaf6a..9b45f6014 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -43,4 +43,15 @@ describe(" — 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 `

    `. 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 `

    ` + // 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( undefined} />); + expect(html).toMatch(/]*>Dashboard<\/h1>/); + }); }); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 629046bc0..e186a7b13 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -1040,11 +1040,11 @@ export function CoachConversation({ {docScopeText}

    -

    +

    {t("insights.coach.attach.fencedHint")}

    {anyPendingUnindexed ? ( -

    +

    {t("insights.coach.attach.indexingHint")}

    ) : null} @@ -1143,9 +1143,7 @@ export function CoachConversation({ {renderDescription ? ( renderDescription(tagline) ) : ( -

    - {tagline} -

    +

    {tagline}

    )}
    @@ -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" /> -

    +

    {notes.length} / {NOTES_MAX}

    diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index b8622fcc0..76f1f4686 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -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")} diff --git a/src/components/onboarding/source-card-grid.tsx b/src/components/onboarding/source-card-grid.tsx index a05e60e47..234f942a4 100644 --- a/src/components/onboarding/source-card-grid.tsx +++ b/src/components/onboarding/source-card-grid.tsx @@ -323,7 +323,7 @@ function SourceCardItem({

    {titleLabel}

    {recommendedLabel ? ( - + {recommendedLabel} ) : null} diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index bc6ef36ff..7e930bd03 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -119,7 +119,7 @@ function ApiEndpointsCard() { className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs" >
    - + {endpoint.method} {endpoint.path} @@ -457,11 +457,11 @@ function ApiTokensCard() { {tok.name}

    {isExpired ? ( - + {t("settings.tokenExpired")} ) : ( - + {t("settings.tokenActive")} )} diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index 534dc2621..d4edba7b5 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -124,7 +124,7 @@ function McpConnectionsCard() {

    {conn.clientName}

    - + {t("settings.mcp.connectionConnected")}
    @@ -438,11 +438,11 @@ function McpTokensCard() { {tok.name}

    {isExpired ? ( - + {t("settings.tokenExpired")} ) : ( - + {t("settings.tokenActive")} )} diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index 549d217d1..e029b9f5f 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -125,13 +125,13 @@ function ShareLinksCard() { {link.label}

    - + {t("settings.sharing.statusActive")} {link.protected && ( @@ -141,7 +141,7 @@ function ShareLinksCard() { {link.documentCount > 0 && ( )} {link.allowFhirApi && ( - + FHIR )} @@ -240,7 +240,7 @@ function ShareLinksCard() {

    {link.label}

    - + {link.revokedAt ? t("settings.sharing.statusRevoked") : t("settings.sharing.statusExpired")}