From e1c6941b205ac8f36158d7103677a3b4730e2c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 10:13:29 +0200 Subject: [PATCH 1/5] fix(ui): lift muted meta text off the sub-xs floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump text-[10px]/text-[11px] to text-xs on muted description, hint, and status-label surfaces that had drifted below the type-scale floor — coach conversation captions, settings/admin status badges, mood confidence pills, side-effect and cycle meta lines. Tabular-nums badges, chart-plot legend/axis chrome, ring geometry, and width-constrained tag tiles keep their compact size by design. --- src/components/admin/api-token-overview-section.tsx | 4 ++-- src/components/admin/backups-section.tsx | 4 ++-- src/components/admin/user-management-section.tsx | 4 ++-- src/components/charts/chart-overlay-controls.tsx | 4 ++-- src/components/cycle/cycle-history-chart.tsx | 2 +- src/components/cycle/cycle-phase-crosstab.tsx | 2 +- .../insights/coach-panel/coach-conversation.tsx | 8 +++----- src/components/insights/coach-panel/coach-drawer.tsx | 2 +- .../insights/coach-panel/guided-dialog-bubbles.tsx | 2 +- src/components/insights/coach-panel/source-chips.tsx | 2 +- src/components/insights/confidence-meter.tsx | 2 +- src/components/insights/mood/mood-better-days.tsx | 2 +- .../insights/mood/mood-factor-metric-crosstab.tsx | 2 +- src/components/insights/mood/mood-tag-influence.tsx | 2 +- .../insights/mood/mood-tag-metric-crosstab.tsx | 2 +- src/components/insights/trend-annotation.tsx | 2 +- .../scheduling/schedule-history-timeline.tsx | 2 +- src/components/medications/side-effects-section.tsx | 4 ++-- src/components/mood/mood-list.tsx | 2 +- src/components/onboarding/source-card-grid.tsx | 2 +- src/components/settings/api-section.tsx | 6 +++--- src/components/settings/mcp-section.tsx | 6 +++--- src/components/settings/sharing-section.tsx | 10 +++++----- 23 files changed, 38 insertions(+), 40 deletions(-) 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/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/cycle/cycle-history-chart.tsx b/src/components/cycle/cycle-history-chart.tsx index 4d5201969..1d761e6cb 100644 --- a/src/components/cycle/cycle-history-chart.tsx +++ b/src/components/cycle/cycle-history-chart.tsx @@ -360,7 +360,7 @@ function StatChip({ style={{ backgroundColor: hue }} /> - + {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/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")} From 156c4574b527a9133345bd856c2411b0585d562e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 10:25:42 +0200 Subject: [PATCH 2/5] test(admin): match the token-overview meta assertion to the text-xs floor The mobile meta lines moved off the sub-xs px size; anchor the wrap assertion on the muted text-xs paragraph instead of the old px class. --- .../admin/__tests__/api-token-overview-responsive.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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)/, ); }); }); From bfe23c17b3fb5e3301369ac37bdfe045f317c4e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 10:25:51 +0200 Subject: [PATCH 3/5] fix(a11y): give the heatmaps a screen-reader day list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mood and medication-compliance heatmaps exposed per-day values only through pointer tooltips. The cells now carry a visually-hidden day list beside the grid — one entry per day that holds a value — so a screen reader reaches the same figures without a tab stop per cell. A cell subtree nested under the SVG's aggregate role=img is pruned from the accessibility tree anyway, and a year-long grid would otherwise flood the keyboard order; the rects stay a pure pointer affordance and the aggregate summary label is unchanged. Tests pin the list and that the cells carry no tab stops. --- .../compliance-heatmap-a11y.test.tsx | 45 +++++++--- .../__tests__/mood-heatmap-a11y.test.tsx | 43 +++++++--- src/components/charts/compliance-heatmap.tsx | 82 +++++++++++-------- src/components/charts/mood-heatmap.tsx | 65 ++++++++------- 4 files changed, 150 insertions(+), 85 deletions(-) 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/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 && (
    Date: Sat, 18 Jul 2026 10:26:05 +0200 Subject: [PATCH 4/5] fix(a11y): heading semantics for two metric sub-page sections The target-summary and coach-read strip tiles sit directly under a metric sub-page's h1, alongside the stat strip that already renders its title as an h2. Render their titles as real h2 headings too via the existing titleAs prop, so heading navigation lands on each section. A test pins that the dashboard exposes its page-level h1 through the shared page header (no duplicate sr-only heading needed). --- .../dashboard/__tests__/dashboard-header.test.tsx | 11 +++++++++++ src/components/insights/derived/coach-read-strip.tsx | 3 +++ src/components/insights/metric-target-summary.tsx | 3 +++ 3 files changed, 17 insertions(+) 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/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 8971fd4ea..94a847fee 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -123,6 +123,9 @@ export function CoachReadStrip({ diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 41d4eec69..aec8c9079 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -316,6 +316,9 @@ function TargetReferencePanel({ Date: Sat, 18 Jul 2026 10:29:43 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20v1.30.6=20=E2=80=94=20acc?= =?UTF-8?q?essibility=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) 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/*`