From 7493ec48e453749cc48c3c8a55a81feb57996114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 16:20:26 +0200 Subject: [PATCH 01/15] Fit the sidebar rail to short windows and hide its native scrollbar On a 13-inch laptop the rail's list overflowed and a classic scrollbar with arrow buttons appeared inside it. The rows now step down by window height (gaps close at 940 px, 36 px rows at 820 px, 32 px at 740 px), so 1366 x 768 fits every entry collapsed and expanded. Where the list still scrolls, the native scrollbar is hidden, fades mark the edge that has more, and a focused row scrolls clear of the fades. --- e2e/sidebar-expand-no-scrollbar.spec.ts | 36 ++++++ src/components/layout/sidebar-nav.tsx | 142 ++++++++++++++++++++---- 2 files changed, 156 insertions(+), 22 deletions(-) diff --git a/e2e/sidebar-expand-no-scrollbar.spec.ts b/e2e/sidebar-expand-no-scrollbar.spec.ts index 71b8d73e8..cb136e1df 100644 --- a/e2e/sidebar-expand-no-scrollbar.spec.ts +++ b/e2e/sidebar-expand-no-scrollbar.spec.ts @@ -133,7 +133,43 @@ test.describe("sidebar width transition", () => { }); // With room to spare there is no edge: the default rail is unchanged. + // No native scrollbar in the rail (a classic one with arrow buttons + // read as a foreign body); a fade marks the edge that has more. + expect( + await nav.evaluate((el) => getComputedStyle(el).scrollbarWidth), + ).toBe("none"); + expect( + await nav.evaluate( + (el) => (el as HTMLElement).offsetWidth - el.clientWidth, + ), + ).toBe(0); + await expect(nav).toHaveAttribute("data-fade-top", "true"); + await expect(nav).not.toHaveAttribute("data-fade-bottom", "true"); + await nav.evaluate((el) => { + el.scrollTop = 0; + }); + await expect(nav).toHaveAttribute("data-fade-bottom", "true"); + await expect(nav).not.toHaveAttribute("data-fade-top", "true"); + await page.setViewportSize({ width: 1280, height: 1400 }); await expect(footer).not.toHaveAttribute("data-nav-overflows", "true"); + await expect(nav).not.toHaveAttribute("data-fade-bottom", "true"); + }); + + test("a 1366 × 768 window fits the rail without scrolling, collapsed and expanded", async ({ + page, + }) => { + await page.setViewportSize({ width: 1366, height: 768 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const nav = page.locator("aside nav").first(); + const toggle = page.locator('[data-slot="sidebar-collapse-toggle"]'); + await expect(nav).toBeVisible(); + for (let i = 0; i < 2; i++) { + await expect + .poll(() => nav.evaluate((el) => el.scrollHeight - el.clientHeight)) + .toBeLessThanOrEqual(0); + await toggle.click(); + await page.waitForTimeout(300); + } }); }); diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 033882f4d..2e2bd4af9 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -248,7 +248,12 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { if (collapsed) { return ( -
+
diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index d42ffa50f..386510a8f 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -219,7 +219,9 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { className={cn( "group relative flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5", "text-sm transition-colors", - "text-muted-foreground hover:bg-muted/40 hover:text-foreground", + // The title is the reader's own content: foreground, + // with the timestamp below as the muted meta line. + "text-foreground hover:bg-muted/40", )} >
+ // The shell owns the page gutter; a second one here doubled it. + ); } diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 5d639e3f9..4262f88c2 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -386,8 +386,9 @@ export default function PrivacyPage() { bodyEn={ <>

- HealthLog is an open-source, self-hostable personal-health- - tracking application. Source code is published at{" "} + HealthLog is an open-source, self-hostable + personal-health-tracking application. Source code is published + at{" "}

The controller under Art. 4 (7) GDPR is the operator of this - instance (an individual based in Germany). Self- hosted + instance (an individual based in Germany). Self-hosted deployments controlled by a different operator are governed by that operator's own privacy policy; this document applies only to this instance. @@ -454,7 +455,7 @@ export default function PrivacyPage() {

  • Profil-Metadaten: Sprache, Zeitzone, Geburtsdatum (optional), - biologisches Geschlecht (optional), Körper- größe (optional) — + biologisches Geschlecht (optional), Körpergröße (optional) — zur Berechnung altersgerechter Zielbereiche und des BMI.
  • @@ -462,7 +463,7 @@ export default function PrivacyPage() { 2.2 Manuell erfasste Gesundheitsdaten

    - Jeder in den App-Formularen eingegebene Wert, einschließ- lich + Jeder in den App-Formularen eingegebene Wert, einschließlich Zeitstempel, optionaler Notiz und Metrik-Typ. Die vollständige Aufzählung der unterstützten Metriken steht im OpenAPI-Schema; die nutzerrelevante Teilmenge umfasst Körpergewicht, Körperfett, @@ -587,9 +588,9 @@ export default function PrivacyPage() {

    Schreibzugriff wird für eine Teilmenge angefordert (Körpermasse, - Blutdruck systolisch / diastolisch, Blut- zucker), damit - manuelle Einträge in der iOS-App zurück nach HealthKit fließen - können. Der Nutzer steuert beide Richtungen über die + Blutdruck systolisch / diastolisch, Blutzucker), damit manuelle + Einträge in der iOS-App zurück nach HealthKit fließen können. + Der Nutzer steuert beide Richtungen über die Berechtigungsoberfläche der iOS- Health-App und kann jederzeit widerrufen.

    @@ -598,25 +599,24 @@ export default function PrivacyPage() {

    Verbindet der Nutzer ein Withings-Konto, speichert HealthLog die - OAuth-Refresh- und -Access-Token (spalten- weise verschlüsselt) + OAuth-Refresh- und -Access-Token (spaltenweise verschlüsselt) sowie die Withings-Nutzerkennung. Webhook-getriebene - Folgesynchronisierungen ziehen Körper- gewicht, Fettanteil, + Folgesynchronisierungen ziehen Körpergewicht, Fettanteil, fettfreie Masse, Muskelmasse, Knochenmasse, Wassergehalt, Magermasse, Hydration, Blutdruck (systolisch / diastolisch) und - Puls, Blut- zucker, Körpertemperatur (Basal- und - Hauttemperatur), Aktivitätssummen (Schritte, Distanz, aktive - Kalorien), Schlafsitzungen mit Pro-Stadien-Segmenten (Awake / - Light / Deep / REM) sowie SpO₂ und Herzfrequenzvariabilität, - soweit vorhanden. + Puls, Blutzucker, Körpertemperatur (Basal- und Hauttemperatur), + Aktivitätssummen (Schritte, Distanz, aktive Kalorien), + Schlafsitzungen mit Pro-Stadien-Segmenten (Awake / Light / Deep + / REM) sowie SpO₂ und Herzfrequenzvariabilität, soweit + vorhanden.

    2.5 Medikamente

    • - Aktive Verordnungen: Wirkstoffname, Stärke, Verabreich- - ungsweg, Einnahmeplan, Behandlungsklasse (Standard oder - GLP-1). + Aktive Verordnungen: Wirkstoffname, Stärke, Verabreichungsweg, + Einnahmeplan, Behandlungsklasse (Standard oder GLP-1).
    • Einnahme-Ereignisse: geplante Zeit, tatsächliche Zeit, Status @@ -716,8 +716,8 @@ export default function PrivacyPage() { X-Device-Id {" "} - -Header — für Mehrgeräte-Synchronisation und Miss- - brauchsschutz. + -Header — für Mehrgeräte-Synchronisation und + Missbrauchsschutz.
    • Apple-Push-Notification-Service-(APNs)-Geräte-Token und @@ -759,8 +759,8 @@ export default function PrivacyPage() {
      • - Keine Werbe-Identifier, Fingerprints oder anwendungs- - übergreifenden Tracker. + Keine Werbe-Identifier, Fingerprints oder + anwendungsübergreifenden Tracker.
      • Keine Zahlungsdaten (es gibt keinen Bezahltarif).
      • @@ -938,11 +938,11 @@ export default function PrivacyPage() {

      - Write access is requested for a subset (body mass, blood- - pressure systolic / diastolic, blood glucose) so that manual - entries made inside the iOS app can flow back into HealthKit. - The user controls both directions in the iOS Health app's - permission surface and may revoke at any time. + Write access is requested for a subset (body mass, + blood-pressure systolic / diastolic, blood glucose) so that + manual entries made inside the iOS app can flow back into + HealthKit. The user controls both directions in the iOS Health + app's permission surface and may revoke at any time.

      2.4 Withings sync (optional) @@ -1057,8 +1057,8 @@ export default function PrivacyPage() {

      • - A device identifier (random UUID generated client- side, - stored in the iOS Keychain) sent as the{" "} + A device identifier (random UUID generated client-side, stored + in the iOS Keychain) sent as the{" "} X-Device-Id {" "} @@ -1135,16 +1135,15 @@ export default function PrivacyPage() { Authentifizierungsdaten {" "} — Identifikation des Nutzers und Sicherung der Sitzung. - Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO (Vertrags- - erfüllung). + Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).
      • Gesundheitsdaten {" "} — Darstellung von Verläufen, Berechnung der Zielwerte- Treue, - Erzeugung des Coach-Snapshots auf Abruf. Rechts- grundlage: Art. - 9 Abs. 2 lit. a DSGVO (ausdrückliche Einwilligung für Daten + Erzeugung des Coach-Snapshots auf Abruf. Rechtsgrundlage: Art. 9 + Abs. 2 lit. a DSGVO (ausdrückliche Einwilligung für Daten besonderer Kategorien).
      • @@ -1420,8 +1419,8 @@ export default function PrivacyPage() { Kein HealthLog-iCloud-Speicher {" "} - — HealthLog speichert Server-Datenbank, lokale Gesundheits- - caches, Offline-Outbox, ECG- und Workout-Payloads, + — HealthLog speichert Server-Datenbank, lokale + Gesundheits-Caches, Offline-Outbox, ECG- und Workout-Payloads, KI-Transkript-Cache, Medikamentenplaner-Datenbank sowie Widget- und Watch-Snapshots weder in iCloud noch in CloudKit. Lokale gesundheitsbezogene Speicher liegen in geschütztem App- oder @@ -1587,10 +1586,10 @@ export default function PrivacyPage() { Widerspruch und automatisierte Entscheidungen (Art. 21–22) {" "} - — der KI-Coach trifft keine automatisierten Entscheid- ungen im + — der KI-Coach trifft keine automatisierten Entscheidungen im Sinne von Art. 22 DSGVO. Er erzeugt Texte zum Nachlesen; ohne ausdrückliche Bestätigung wird keine Aktion ausgeführt. Siehe - Abschnitt 7 zur Medizinpro- dukte-Grenze. + Abschnitt 7 zur Medizinprodukte-Grenze.
      • diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 7bb5e2876..7023528e6 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -271,7 +271,14 @@ export function CycleView() { days: verdict.overdueDays, }) : t("cycle.ring.periodOverdue") - : t("cycle.ring.caption")} + : // The ring shows the bare number; the caption under it + // reads as a whole phrase ("Tag 9 deines Zyklus"), not + // a fragment hanging off the ring. + verdict?.dayOfCycle != null + ? t("cycle.insightsSummary.currentDay", { + day: verdict.dayOfCycle, + }) + : t("cycle.ring.caption")}

        ) : null} {/* Log CTA — first period when no cycle exists yet, next period diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index 822fc0837..fada9076b 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -86,7 +86,8 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) { ) : null} {log.note ? ( -

        + // User-written text reads in the content colour, never muted. +

        {log.note}

        ) : null} diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index 78316921d..1c91a139e 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -98,7 +98,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { onClick={() => openSheet("new")} > - {t("practitioners.add")} + {t("common.add")} ) : null; diff --git a/src/components/records/profile-summary.tsx b/src/components/records/profile-summary.tsx index ac2e5f407..e6e33f8d1 100644 --- a/src/components/records/profile-summary.tsx +++ b/src/components/records/profile-summary.tsx @@ -130,7 +130,9 @@ export function ProfileSummaryPage() { }); return ( -
        + // The shell owns the page frame (gutter, width, top space) and the + //
        landmark; this page only sets its rhythm. +
        {summary.isPending ? (

        @@ -141,6 +143,6 @@ export function ProfileSummaryPage() { void summary.refetch()} /> ) : null} {summary.data ? : null} -

        +
    ); } diff --git a/src/components/ui/__tests__/empty-state.test.tsx b/src/components/ui/__tests__/empty-state.test.tsx index 1247e09bd..e24cb92ab 100644 --- a/src/components/ui/__tests__/empty-state.test.tsx +++ b/src/components/ui/__tests__/empty-state.test.tsx @@ -46,4 +46,13 @@ describe("", () => { expect(card).toContain("border-dashed"); expect(plain).not.toContain("border-dashed"); }); + + it("insets the card variant's content from its border, not the plain one", () => { + const card = renderToStaticMarkup(); + const plain = renderToStaticMarkup( + , + ); + expect(card).toMatch(/class="[^"]*\bpx-4\b/); + expect(plain).not.toMatch(/\bpx-4\b/); + }); }); diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx index 06fdc35d5..3f736a7ed 100644 --- a/src/components/ui/empty-state.tsx +++ b/src/components/ui/empty-state.tsx @@ -64,6 +64,9 @@ function EmptyState({ className={cn( "flex flex-col items-center justify-center text-center", size === "compact" ? "gap-2 py-4" : "gap-3 py-8", + // The card variant paints a border, so its content insets from it: + // a full-width phone CTA otherwise ran edge to edge into the frame. + variant === "card" && "px-4", )} > {icon ? ( From 4d18c4e1015348c53e55622fdb009ec72ec401b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:02:01 +0200 Subject: [PATCH 10/15] Stop insights sub-pages from shifting after first paint Weight, blood pressure and pulse painted no stat strip until their read landed, so the read strip and chart jumped down a card's height; the strip now holds its place with the skeleton the HealthKit pages already used, shared from the strip itself. The tab strip's scroll arrows render from the start and only hide while the row fits, instead of mounting once it measured as overflowing and shoving every pill sideways. The sleep page shows the stage chart's skeleton while loading rather than a 'no stage data yet' card. Load shift on weight, blood pressure and pulse drops from 0.3 to under 0.01. --- src/app/insights/blood-pressure/page.tsx | 8 +- src/app/insights/pulse/page.tsx | 7 +- src/app/insights/weight/page.tsx | 7 +- .../__tests__/insights-tab-strip.test.tsx | 20 +++++ .../__tests__/sleep-overview-loading.test.tsx | 40 ++++++++++ .../insights/healthkit-metric-page.tsx | 38 ++-------- .../insights/insights-tab-strip.tsx | 73 ++++++++++--------- src/components/insights/metric-stat-strip.tsx | 59 +++++++++++++++ src/components/insights/sleep-overview.tsx | 8 +- 9 files changed, 188 insertions(+), 72 deletions(-) create mode 100644 src/components/insights/__tests__/sleep-overview-loading.test.tsx diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 754327c50..1a5782055 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -46,8 +46,11 @@ export default function InsightsBlutdruckPage() { const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(user != null); - const { data: analytics, isEmpty } = - useInsightsAnalytics("BLOOD_PRESSURE_SYS"); + const { + data: analytics, + isEmpty, + isLoading: analyticsLoading, + } = useInsightsAnalytics("BLOOD_PRESSURE_SYS"); // v1.12.4 — blood pressure is two series, so it stacks two stat strips // (systolic / diastolic) rather than the single-series strip the other // metrics carry. Both summaries ride the same `summaries` slice. @@ -128,6 +131,7 @@ export default function InsightsBlutdruckPage() { // brushing the single chart reports per-type windowed stats and each // column reads its own half. — pill row clip box (v1.16.8)", () => { expect(scroller).toContain("-mx-1"); }); }); + +describe(" — scroll arrows hold their slot", () => { + it("renders both arrows before the row is measured, hidden but in place", () => { + // Mounting the arrows only once the row measured as overflowing shifted + // every pill sideways right after first paint (a layout shift on every + // insights page). They render from the start and only hide. + const html = render(); + for (const slot of [ + "insights-tab-strip-scroll-left", + "insights-tab-strip-scroll-right", + ]) { + const tag = html.match( + new RegExp(`]*data-slot="${slot}"[^>]*>`), + ); + expect(tag?.[0]).toBeDefined(); + expect(tag?.[0]).toContain("invisible"); + expect(tag?.[0]).toContain('tabindex="-1"'); + } + }); +}); diff --git a/src/components/insights/__tests__/sleep-overview-loading.test.tsx b/src/components/insights/__tests__/sleep-overview-loading.test.tsx new file mode 100644 index 000000000..0e6164dd7 --- /dev/null +++ b/src/components/insights/__tests__/sleep-overview-loading.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * While the analytics read is in flight the stage chart holds its place with + * a skeleton. Painting the "no stage data yet" card first said something + * untrue and pushed the duration chart down when the bar replaced it. + */ + +const state = { isLoading: true, data: undefined as unknown }; + +vi.mock("@/lib/queries/use-analytics-query", () => ({ + useAnalyticsQuery: () => state, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { timezone: "UTC" } }), +})); +vi.mock("@/hooks/use-insights-layout-prefs", () => ({ + useInsightsLayoutPrefs: () => ({ compareBaseline: "none" }), +})); +vi.mock("../sleep-duration-chart", () => ({ + SleepDurationChart: () =>
    , +})); + +import { SleepOverview } from "../sleep-overview"; +import en from "../../../../messages/en.json"; + +describe("SleepOverview — loading", () => { + it("paints a skeleton, not the 'no stage data' card, while loading", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).not.toContain(en.insights.sleep.stages.unavailable); + expect(html).toContain('data-slot="duration-chart"'); + }); +}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 60c6a2109..9e023d7eb 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -17,13 +17,14 @@ import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import { metricFractionDigits } from "@/lib/measurements/value-domain"; import { Button } from "@/components/ui/button"; import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { Card, CardContent } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { MetricStatusCard } from "@/components/insights/metric-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; -import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { + MetricStatStrip, + MetricStatStripSkeleton, +} from "@/components/insights/metric-stat-strip"; import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; @@ -371,7 +372,7 @@ export function HealthKitMetricPage({ description={description} explainerMetric={explainerMetric} explainerParams={explainerParams} - statStrip={} + statStrip={} > @@ -538,32 +539,3 @@ export function HealthKitMetricPage({ ); } - -/** - * v1.12.7 — layout-stable loading shell for the stat strip slot. Mirrors - * the loaded `` card chrome (denser `py-3` rhythm, one - * header row + a four-up grid) so the page does not jump when the analytics - * read lands. Decorative — hidden from assistive tech; the chart skeleton - * below it carries the `aria-busy` announcement. - */ -function StatStripSkeleton() { - return ( - - ); -} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 9bbcf005c..3e8c9f920 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -822,23 +822,27 @@ function InsightsTabStripImpl({ )} >
    - {pillsOverflow && ( - - )} + {/* Both arrows always hold their slot (sm+) and only hide while the + row fits: mounting them once the row measured as overflowing + shifted every pill sideways a moment after first paint. */} +
    - {pillsOverflow && ( - - )} + {/* v1.4.27 MB7 / CF-72 — right-edge fade. The gradient absolute-positions over the rightmost ~24 px of the strip so the last visible pill softly fades into the background diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 229b8d7a0..d9682e215 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -5,6 +5,7 @@ import { Sigma } from "lucide-react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import type { DataSummary } from "@/lib/analytics/trends"; import type { MetricWindowStats } from "@/lib/charts/window-stats"; @@ -139,6 +140,13 @@ type MetricStatStripProps = Partial & { * Single-series mode derives the label from `seriesLabel` instead. */ groupLabel?: string; + /** + * The read behind the summary is still in flight. The strip then holds its + * place with a card of the same anatomy instead of painting nothing: it + * sits at the top of the sub-page, so arriving later pushed the read strip, + * the chart and everything below it down the page. + */ + pending?: boolean; }; /** @@ -243,9 +251,14 @@ export function MetricStatStrip({ titleAs, series, groupLabel, + pending = false, }: MetricStatStripProps) { const { t } = useTranslations(); + if (pending) { + return ; + } + // v1.12.7 — multi-series mode: ONE Card, columns side by side. The card // self-gates to nothing only when EVERY series is empty so a one-sided // metric (e.g. only systolic logged) still paints its half. @@ -310,3 +323,49 @@ export function MetricStatStrip({ ); } + +/** + * Layout-stable loading shell for the stat strip: the loaded card's chrome + * (compact rhythm, a header row, a four-up grid per series, two series side + * by side from `md`) so the page does not jump when the read lands. + * Decorative — hidden from assistive tech; the chart skeleton below carries + * the busy announcement. + */ +export function MetricStatStripSkeleton({ + seriesCount = 1, +}: { + seriesCount?: number; +}) { + return ( + + ); +} diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx index 58117e271..dadd2e58c 100644 --- a/src/components/insights/sleep-overview.tsx +++ b/src/components/insights/sleep-overview.tsx @@ -122,7 +122,13 @@ export function SleepOverview() { return (
    - {sleepStages ? ( + {/* While the read is in flight the bar's own skeleton holds its + place: the "stages unavailable" card is a statement about the data, + and painting it before the data arrived both said something untrue + and pushed the duration chart down when the bar replaced it. */} + {isLoading ? ( + + ) : sleepStages ? ( ) : ( From 0f51a828c4850285a2ab3bf50a16f0080d51b814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:10:41 +0200 Subject: [PATCH 11/15] Keep chart ticks readable and insights strings in the reader's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A word-length unit on the y-axis ticks ('Schritte', 'Atemzüge/min', 'mL/(kg·min)') was wider than the axis gutter and lost its leading digits; such units stay off the ticks, where the stat strip above already names them. The sleep stage toggle reads '7T / 14T / 30T' like every other range control, the 'Mood entries' and '7-day' target sources are named in the reader's language, and the BMI and medication empty states ask the coach in that language too. --- messages/de.json | 11 ++++++++ messages/en.json | 11 ++++++++ messages/es.json | 11 ++++++++ messages/fr.json | 11 ++++++++ messages/it.json | 11 ++++++++ messages/ko.json | 11 ++++++++ messages/pl.json | 11 ++++++++ src/app/insights/bmi/page.tsx | 2 +- src/app/insights/medications/page.tsx | 4 ++- src/components/charts/health-chart.tsx | 4 +-- .../metric-target-source-name.test.ts | 16 ++++++++++++ .../sleep-stage-stacked-bar.test.tsx | 3 +++ .../insights/metric-target-summary.tsx | 26 +++++++++++++++++-- .../insights/sleep-stage-stacked-bar.tsx | 4 ++- src/lib/charts/__tests__/axis-unit.test.ts | 14 +++++++++- src/lib/charts/axis-unit.ts | 17 ++++++++++++ 16 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 src/components/insights/__tests__/metric-target-source-name.test.ts diff --git a/messages/de.json b/messages/de.json index c28878446..0b1a9ee93 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2407,6 +2407,7 @@ "days7Label": "7T", "days7Title": "Letzte 7 Tage", "days30Label": "30T", + "daysNLabel": "{days}T", "days30Title": "Letzte 30 Tage", "days90Label": "90T", "days90Title": "Letzte 90 Tage", @@ -4745,6 +4746,12 @@ "settingsBody": "HealthLog funktioniert vollständig ohne KI. Verbinde unten einen Anbieter, wenn du ein tägliches Briefing, kurze Notizen zu deinen Werten und den Coach möchtest.", "action": "KI einrichten", "dismiss": "Nicht jetzt" + }, + "bmiHeight": { + "coachPrefill": "Ich habe meine Körpergröße noch nicht eingetragen — warum ist der BMI wichtig, und was sollte ich wissen, bevor ich sie eintrage?" + }, + "medicationsEmpty": { + "coachPrefill": "Ich habe noch keine Medikamente angelegt — was sollte ich wissen, bevor ich hier anfange, meine Therapietreue zu verfolgen?" } }, "thresholds": { @@ -8497,6 +8504,10 @@ "currentValue": "Aktuell: {value} {unit}", "targetRangeValue": "Zielbereich: {min}–{max} {unit}", "sourceLabel": "Quelle: {source}", + "sourceNames": { + "moodEntries": "Stimmungseinträge", + "sevenDays": "7 Tage" + }, "inTarget": "Im Zielbereich", "aboveTarget": "{delta} {unit} über Zielbereich", "belowTarget": "{delta} {unit} unter Zielbereich", diff --git a/messages/en.json b/messages/en.json index 7198453d6..be600a3c7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2407,6 +2407,7 @@ "days7Label": "7d", "days7Title": "Last 7 days", "days30Label": "30d", + "daysNLabel": "{days}d", "days30Title": "Last 30 days", "days90Label": "90d", "days90Title": "Last 90 days", @@ -4745,6 +4746,12 @@ "settingsBody": "HealthLog works fully without AI. Connect a provider below if you want a daily briefing, short notes on your readings and the Coach.", "action": "Set up AI", "dismiss": "Not now" + }, + "bmiHeight": { + "coachPrefill": "I haven’t set my height yet — why does BMI matter, and what should I know before I configure it?" + }, + "medicationsEmpty": { + "coachPrefill": "I haven’t added any medications yet — what should I know before I start tracking medication compliance here?" } }, "thresholds": { @@ -8497,6 +8504,10 @@ "currentValue": "Current: {value} {unit}", "targetRangeValue": "Target: {min}–{max} {unit}", "sourceLabel": "Source: {source}", + "sourceNames": { + "moodEntries": "Mood entries", + "sevenDays": "7 days" + }, "inTarget": "Within target range", "aboveTarget": "{delta} {unit} above target", "belowTarget": "{delta} {unit} below target", diff --git a/messages/es.json b/messages/es.json index fea607c2d..5c65d82e0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2407,6 +2407,7 @@ "days7Label": "7 d", "days7Title": "Últimos 7 días", "days30Label": "30 d", + "daysNLabel": "{days} d", "days30Title": "Últimos 30 días", "days90Label": "90 d", "days90Title": "Últimos 90 días", @@ -4745,6 +4746,12 @@ "settingsBody": "HealthLog funciona por completo sin IA. Conecta un proveedor abajo si quieres un resumen diario, notas breves sobre tus mediciones y el Coach.", "action": "Configurar IA", "dismiss": "Ahora no" + }, + "bmiHeight": { + "coachPrefill": "Todavía no he indicado mi altura: ¿por qué importa el IMC y qué debería saber antes de configurarla?" + }, + "medicationsEmpty": { + "coachPrefill": "Todavía no he añadido medicamentos: ¿qué debería saber antes de empezar a seguir aquí la adherencia?" } }, "thresholds": { @@ -8497,6 +8504,10 @@ "currentValue": "Actual: {value} {unit}", "targetRangeValue": "Rango objetivo: {min}–{max} {unit}", "sourceLabel": "Fuente: {source}", + "sourceNames": { + "moodEntries": "Entradas de ánimo", + "sevenDays": "7 días" + }, "inTarget": "En rango objetivo", "aboveTarget": "{delta} {unit} por encima del rango", "belowTarget": "{delta} {unit} por debajo del rango", diff --git a/messages/fr.json b/messages/fr.json index 7462c5d28..be8f0932c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2407,6 +2407,7 @@ "days7Label": "7 j", "days7Title": "7 derniers jours", "days30Label": "30 j", + "daysNLabel": "{days} j", "days30Title": "30 derniers jours", "days90Label": "90 j", "days90Title": "90 derniers jours", @@ -4745,6 +4746,12 @@ "settingsBody": "HealthLog fonctionne entièrement sans IA. Connectez un fournisseur ci-dessous si vous voulez un briefing quotidien, de courtes notes sur vos mesures et le Coach.", "action": "Configurer l'IA", "dismiss": "Pas maintenant" + }, + "bmiHeight": { + "coachPrefill": "Je n’ai pas encore indiqué ma taille — pourquoi l’IMC compte-t-il, et que dois-je savoir avant de la renseigner ?" + }, + "medicationsEmpty": { + "coachPrefill": "Je n’ai encore ajouté aucun médicament — que dois-je savoir avant de suivre ici l’observance ?" } }, "thresholds": { @@ -8497,6 +8504,10 @@ "currentValue": "Actuel : {value} {unit}", "targetRangeValue": "Plage cible : {min}–{max} {unit}", "sourceLabel": "Source : {source}", + "sourceNames": { + "moodEntries": "Entrées d’humeur", + "sevenDays": "7 jours" + }, "inTarget": "Dans la plage cible", "aboveTarget": "{delta} {unit} au-dessus de la plage", "belowTarget": "{delta} {unit} en dessous de la plage", diff --git a/messages/it.json b/messages/it.json index 145d58031..5186016ef 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2407,6 +2407,7 @@ "days7Label": "7 g", "days7Title": "Ultimi 7 giorni", "days30Label": "30 g", + "daysNLabel": "{days} g", "days30Title": "Ultimi 30 giorni", "days90Label": "90 g", "days90Title": "Ultimi 90 giorni", @@ -4745,6 +4746,12 @@ "settingsBody": "HealthLog funziona completamente senza IA. Collega un fornitore qui sotto se vuoi un riepilogo giornaliero, brevi note sulle tue misurazioni e il Coach.", "action": "Configura IA", "dismiss": "Non ora" + }, + "bmiHeight": { + "coachPrefill": "Non ho ancora inserito la mia altezza: perché conta il BMI e cosa dovrei sapere prima di impostarla?" + }, + "medicationsEmpty": { + "coachPrefill": "Non ho ancora aggiunto farmaci: cosa dovrei sapere prima di iniziare a monitorare qui l’aderenza?" } }, "thresholds": { @@ -8497,6 +8504,10 @@ "currentValue": "Attuale: {value} {unit}", "targetRangeValue": "Range obiettivo: {min}–{max} {unit}", "sourceLabel": "Fonte: {source}", + "sourceNames": { + "moodEntries": "Voci dell’umore", + "sevenDays": "7 giorni" + }, "inTarget": "Nel range obiettivo", "aboveTarget": "{delta} {unit} sopra il range", "belowTarget": "{delta} {unit} sotto il range", diff --git a/messages/ko.json b/messages/ko.json index 821498bbf..3d7594b2e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2498,6 +2498,7 @@ "days7Label": "7일", "days7Title": "최근 7일", "days30Label": "30일", + "daysNLabel": "{days}일", "days30Title": "최근 30일", "days90Label": "90일", "days90Title": "최근 90일", @@ -4745,6 +4746,12 @@ "settingsBody": "HealthLog는 AI 없이도 모든 기능이 작동합니다. 일일 브리핑, 측정값에 대한 짧은 메모, 코치를 원하시면 아래에서 제공자를 연결하세요.", "action": "AI 설정", "dismiss": "나중에" + }, + "bmiHeight": { + "coachPrefill": "아직 키를 입력하지 않았어요. BMI가 왜 중요하고, 입력하기 전에 무엇을 알아야 하나요?" + }, + "medicationsEmpty": { + "coachPrefill": "아직 약을 추가하지 않았어요. 여기서 복약 순응도를 기록하기 전에 무엇을 알아야 하나요?" } }, "thresholds": { @@ -8497,6 +8504,10 @@ "currentValue": "현재: {value} {unit}", "targetRangeValue": "목표: {min}–{max} {unit}", "sourceLabel": "출처: {source}", + "sourceNames": { + "moodEntries": "기분 기록", + "sevenDays": "7일" + }, "inTarget": "목표 범위 안", "aboveTarget": "목표보다 {delta} {unit} 높음", "belowTarget": "목표보다 {delta} {unit} 낮음", diff --git a/messages/pl.json b/messages/pl.json index 20eee9b10..e35278cec 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2407,6 +2407,7 @@ "days7Label": "7 dni", "days7Title": "Ostatnie 7 dni", "days30Label": "30 dni", + "daysNLabel": "{days} dni", "days30Title": "Ostatnie 30 dni", "days90Label": "90 dni", "days90Title": "Ostatnie 90 dni", @@ -4745,6 +4746,12 @@ "settingsBody": "HealthLog działa w pełni bez AI. Podłącz dostawcę poniżej, jeśli chcesz codzienne podsumowanie, krótkie notatki o pomiarach i Coacha.", "action": "Skonfiguruj AI", "dismiss": "Nie teraz" + }, + "bmiHeight": { + "coachPrefill": "Nie podałem jeszcze wzrostu — dlaczego BMI ma znaczenie i co powinienem wiedzieć, zanim go ustawię?" + }, + "medicationsEmpty": { + "coachPrefill": "Nie dodałem jeszcze żadnych leków — co powinienem wiedzieć, zanim zacznę tu śledzić przestrzeganie zaleceń?" } }, "thresholds": { @@ -8497,6 +8504,10 @@ "currentValue": "Aktualnie: {value} {unit}", "targetRangeValue": "Zakres docelowy: {min}–{max} {unit}", "sourceLabel": "Źródło: {source}", + "sourceNames": { + "moodEntries": "Wpisy nastroju", + "sevenDays": "7 dni" + }, "inTarget": "W zakresie docelowym", "aboveTarget": "{delta} {unit} powyżej zakresu", "belowTarget": "{delta} {unit} poniżej zakresu", diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index c4e91cb5a..cdc5ea3c6 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -95,7 +95,7 @@ export default function InsightsBmiPage() { } /> - + ); } diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 99aa76753..e6492bf1c 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -208,7 +208,9 @@ export default function InsightsMedikamentePage() { } /> - + ); } diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 3f539c146..beedff5eb 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -76,7 +76,7 @@ import { type MetricWindowStats, } from "@/lib/charts/window-stats"; import { shouldFireDataReady } from "@/lib/charts/data-ready-latch"; -import { axisUnitSuffix } from "@/lib/charts/axis-unit"; +import { axisTickUnitSuffix } from "@/lib/charts/axis-unit"; import { chartPointDayKey } from "@/components/day/chart-day"; import { ChartDayFooter, @@ -2078,7 +2078,7 @@ export function HealthChart({ } unit={ showYAxisUnit - ? axisUnitSuffix(yAxisUnit ?? unit) + ? axisTickUnitSuffix(yAxisUnit ?? unit) : undefined } /> diff --git a/src/components/insights/__tests__/metric-target-source-name.test.ts b/src/components/insights/__tests__/metric-target-source-name.test.ts new file mode 100644 index 000000000..e8077ac9c --- /dev/null +++ b/src/components/insights/__tests__/metric-target-source-name.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { sourceName } from "../metric-target-summary"; + +describe("sourceName", () => { + const t = (key: string) => `t:${key}`; + it("names the plain-description sources in the reader's language", () => { + expect(sourceName("Mood entries", t)).toBe( + "t:targets.sourceNames.moodEntries", + ); + expect(sourceName("7-day", t)).toBe("t:targets.sourceNames.sevenDays"); + }); + it("leaves a citation as it is", () => { + expect(sourceName("ESH 2023", t)).toBe("ESH 2023"); + }); +}); diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 0abdb5b3c..6ea7b026f 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -88,6 +88,9 @@ describe("", () => { const html = render(, "de"); expect(html).toContain("Phasen-Verteilung"); expect(html).toContain("Letzte 7 Nächte"); + // The window toggle speaks the app's range vocabulary, not "7d". + expect(html).toContain(">7T<"); + expect(html).not.toContain(">7d<"); }); it("does not crash when the breakdown carries unknown stage keys", () => { diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 845b1b8d6..513e2bfdd 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -421,12 +421,18 @@ function TargetReferencePanel({ rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs" > - {t("targets.sourceLabel", { source: target.source })} + + {t("targets.sourceLabel", { + source: sourceName(target.source, t), + })} + ) : ( - {t("targets.sourceLabel", { source: target.source })} + {t("targets.sourceLabel", { + source: sourceName(target.source, t), + })} )}
    @@ -434,3 +440,19 @@ function TargetReferencePanel({ ); } + +/** + * Two target sources are plain descriptions rather than a citation ("Mood + * entries", "7-day"); the server sends them in English, so they are named in + * the reader's language here. Every other source is a citation (ESH 2023, + * WHO, AASM/SRS) and reads as it is. + */ +const SOURCE_NAME_KEYS: Record = { + "Mood entries": "targets.sourceNames.moodEntries", + "7-day": "targets.sourceNames.sevenDays", +}; + +export function sourceName(source: string, t: (key: string) => string): string { + const key = SOURCE_NAME_KEYS[source]; + return key ? t(key) : source; +} diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index ccf2e94c0..7bb21701d 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -396,7 +396,9 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { aria-pressed={windowDays === w} data-slot={`sleep-stage-window-${w}`} > - {w}d + {/* The page's other range controls read "7T / 30T"; this + one printed an English "7d" beside them. */} + {t("charts.daysNLabel", { days: w })} ))}
    diff --git a/src/lib/charts/__tests__/axis-unit.test.ts b/src/lib/charts/__tests__/axis-unit.test.ts index 6ff292885..121a0cce3 100644 --- a/src/lib/charts/__tests__/axis-unit.test.ts +++ b/src/lib/charts/__tests__/axis-unit.test.ts @@ -7,7 +7,7 @@ import { stripComments, walkSourceFiles, } from "@/__tests__/helpers/source-files"; -import { axisUnitSuffix } from "../axis-unit"; +import { axisTickUnitSuffix, axisUnitSuffix } from "../axis-unit"; const COMPONENTS = join(process.cwd(), "src", "components"); @@ -54,3 +54,15 @@ describe("axis unit suffix", () => { expect(offenders).toEqual([]); }); }); + +describe("axisTickUnitSuffix", () => { + it("keeps a short unit on the ticks", () => { + expect(axisTickUnitSuffix("bpm")).toBe(axisUnitSuffix("bpm")); + expect(axisTickUnitSuffix("mg/dL")).toBe(axisUnitSuffix("mg/dL")); + }); + it("keeps a word-length unit off the ticks, where it was clipped", () => { + expect(axisTickUnitSuffix("Schritte")).toBeUndefined(); + expect(axisTickUnitSuffix("Atemzüge/min")).toBeUndefined(); + expect(axisTickUnitSuffix("mL/(kg·min)")).toBeUndefined(); + }); +}); diff --git a/src/lib/charts/axis-unit.ts b/src/lib/charts/axis-unit.ts index 3b7c81811..abdd2ac50 100644 --- a/src/lib/charts/axis-unit.ts +++ b/src/lib/charts/axis-unit.ts @@ -13,3 +13,20 @@ export function axisUnitSuffix( ): string | undefined { return unit ? ` ${unit}` : undefined; } + +/** + * The longest unit a tick label carries. A word-length unit ("Schritte", + * "Atemzüge/min", "mL/(kg·min)") made every tick wider than the fixed y-axis + * gutter, and the label was clipped at its left edge (".744 Schritte"). Such + * a unit stays off the ticks; the stat strip and the tile above the chart + * already name it. + */ +export const MAX_TICK_UNIT_LENGTH = 5; + +/** The tick suffix for `unit`, or none when the unit is too long to fit. */ +export function axisTickUnitSuffix( + unit: string | null | undefined, +): string | undefined { + if (!unit || unit.length > MAX_TICK_UNIT_LENGTH) return undefined; + return axisUnitSuffix(unit); +} From ec272c9150368f295034aa8b30e0d09ef8438c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:13:49 +0200 Subject: [PATCH 12/15] =?UTF-8?q?Show=20the=20callback=20path,=20not=20'un?= =?UTF-8?q?defined/api/=E2=80=A6',=20on=20an=20unset=20app=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With NEXT_PUBLIC_APP_URL unset, some providers spliced the missing value into their redirect template and the integration cards told the reader to register 'undefined/api/polar/callback'. A callback that is not an absolute http(s) URL now counts as not configured, so the card shows the fixed path it shows for every other unconfigured provider. --- src/lib/integrations/__tests__/callback-urls.test.ts | 12 ++++++++++++ src/lib/integrations/callback-urls.ts | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/lib/integrations/__tests__/callback-urls.test.ts b/src/lib/integrations/__tests__/callback-urls.test.ts index 27d25c2e3..3214448c3 100644 --- a/src/lib/integrations/__tests__/callback-urls.test.ts +++ b/src/lib/integrations/__tests__/callback-urls.test.ts @@ -110,4 +110,16 @@ describe("getIntegrationCallbackUrls", () => { expect(urls.withings).toBe("http://health.example/api/withings/callback"); expect(urls.whoop).toBe("http://health.example/api/whoop/callback"); }); + + it("reports null rather than 'undefined/api/…' when the app URL is unset", () => { + // A resolver that splices the unset variable into its template would + // otherwise hand the card "undefined/api/polar/callback" to show. + const urls = getIntegrationCallbackUrls(); + for (const provider of INTEGRATION_CALLBACK_PROVIDERS) { + expect(urls[provider] ?? "", provider).not.toMatch(/^undefined/); + if (urls[provider] !== null) { + expect(urls[provider], provider).toMatch(/^https?:\/\//); + } + } + }); }); diff --git a/src/lib/integrations/callback-urls.ts b/src/lib/integrations/callback-urls.ts index d9e7036bf..5f6b00d39 100644 --- a/src/lib/integrations/callback-urls.ts +++ b/src/lib/integrations/callback-urls.ts @@ -59,7 +59,12 @@ const RESOLVERS: Record string> = { function resolve(provider: IntegrationCallbackProvider): string | null { try { - return RESOLVERS[provider](); + const url = RESOLVERS[provider](); + // Some resolvers splice an unset NEXT_PUBLIC_APP_URL into the template + // and hand back "undefined/api/…/callback". That is not a URL anyone can + // paste into a vendor form; report it as not configured so the card + // shows the path instead. + return /^https?:\/\//i.test(url) ? url : null; } catch { return null; } From 105c293544c70338e18842efbcdcc17826aec6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:34:20 +0200 Subject: [PATCH 13/15] Size the catalog row skeleton to the row, drop the sparkline focus stop The catalog's loading rows were 6 px shorter than the loaded ones, so the groups below moved when a probe landed. The decorative score sparklines sit in an aria-hidden wrapper, and Recharts' accessibility layer put a focusable surface inside it. --- src/app/insights/catalog/page.tsx | 6 ++++-- src/components/insights/derived/delta-sparkline.tsx | 9 ++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index 5f050dee2..db106235c 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -122,8 +122,10 @@ function CatalogRowSkeleton() { data-slot="catalog-row-skeleton" className="flex items-start justify-between gap-3 py-2" > -
    - + {/* Sized to the loaded row (a 22 px name-and-badge line over a 16 px + source line), so the groups below do not move when it lands. */} +
    +
    diff --git a/src/components/insights/derived/delta-sparkline.tsx b/src/components/insights/derived/delta-sparkline.tsx index 9736cbd67..2e7462786 100644 --- a/src/components/insights/derived/delta-sparkline.tsx +++ b/src/components/insights/derived/delta-sparkline.tsx @@ -62,7 +62,14 @@ export function DeltaSparkline({ const hasComparison = data.some((point) => point.b !== undefined); return ( - + {/* A sparkline is decoration inside an aria-hidden wrapper: without + the accessibility layer Recharts puts a focusable surface in it, + and a focus stop nobody can perceive is an axe failure. */} + From 4673973a796c90880c8c8f530afa8a030bc8fda3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:34:21 +0200 Subject: [PATCH 14/15] Title the mood sheet like the other capture sheets; fit the documents search hint The mood page's sheet now reads 'Stimmung erfassen' like the same sheet everywhere else, and the documents search placeholder is short enough to show whole on a phone. --- messages/de.json | 2 +- src/app/mood/page-client.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index 62264bce8..ee6731cd2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10758,7 +10758,7 @@ }, "filter": { "searchLabel": "Dokumente durchsuchen", - "searchPlaceholder": "Titel oder Dateiname suchen…", + "searchPlaceholder": "Titel, Dateiname", "typeGroup": "Dokumenttyp", "conditionGroup": "Erkrankung", "procedureGroup": "Eingriffe und Operationen", diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx index f86962bc5..05e41c7f3 100644 --- a/src/app/mood/page-client.tsx +++ b/src/app/mood/page-client.tsx @@ -126,7 +126,7 @@ export default function MoodPageClient() { } > Date: Thu, 8 Oct 2026 17:52:44 +0200 Subject: [PATCH 15/15] Give touch screens 44 px targets through the primitives A pointer-coarse variant sits beside the pointer-fine one. Under it the default, small and icon buttons, tab triggers, the sidebar rail rows and the phone logo link reach the 44 px touch floor; a mouse keeps the compact desktop density. The rail's height steps now apply to a mouse only. A phone held sideways crosses the md breakpoint at about 390 px of height, where the fixed footer left the list three rows: there the whole rail scrolls as one column, without a native scrollbar. --- src/app/globals.css | 12 ++++++++++++ src/components/layout/sidebar-nav.tsx | 17 ++++++++++++++++- src/components/layout/top-bar.tsx | 2 +- src/components/ui/button.tsx | 8 ++++---- src/components/ui/tabs.tsx | 6 ++++-- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index e2f619321..63361cc9f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -46,6 +46,18 @@ } } +/* + * `pointer-coarse:` — a finger is the primary pointer. The control + * primitives (Button, tab triggers, the sidebar rail rows) grow their hit + * area to the 44 px touch floor under it, so a phone or a touch tablet gets + * reachable targets while a mouse keeps the compact desktop density. + */ +@custom-variant pointer-coarse { + @media (pointer: coarse) { + @slot; + } +} + @theme inline { /* Extra-small breakpoint (30rem / 480px) for the narrowest phones — the default scale starts at sm (640px), which leaves a gap between an iPhone SE diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 2e2bd4af9..755adc995 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -334,11 +334,20 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { // Disjoint height ranges, not nested max-heights: two arbitrary media // variants on one property cascade in stylesheet order, not by how narrow // the query is, so a nested pair let the 36 px step win on a 720 px window. +// The height steps apply to a mouse only. Under a finger (a touch tablet, +// a phone held sideways past the md breakpoint) the rows keep the 44 px +// touch floor and the list scrolls behind its fades instead. const RAIL_ROW_HEIGHT = - "min-h-10 [@media(740px*]:shrink-0"; +const RAIL_SHORT_TOUCH_NAV = + "[@media(pointer:coarse)_and_(height<=520px)]:flex-none [@media(pointer:coarse)_and_(height<=520px)]:overflow-visible"; + /** Rail row, collapsed (icon only) or expanded (icon + label). */ function railRowClass(collapsed: boolean): string { return cn( @@ -689,6 +698,11 @@ export function SidebarNav() { data-slot="sidebar-column" className={cn( "flex h-full w-full flex-col", + // A phone held sideways crosses the md breakpoint at ~390 px of + // height: the fixed footer and user rows left the list three + // rows. There the whole column scrolls as one (no native bar), + // so every entry stays reachable at the 44 px touch floor. + RAIL_SHORT_TOUCH_COLUMN, !collapsed && SIDEBAR_EXPANDED_CONTENT_WIDTH, )} > @@ -729,6 +743,7 @@ export function SidebarNav() { // the focused row clear of them. className={cn( "flex-1 scroll-py-6 [scrollbar-width:none] overflow-y-auto [&::-webkit-scrollbar]:hidden", + RAIL_SHORT_TOUCH_NAV, collapsed ? "p-1.5" : "p-3", RAIL_SHORT_PY, )} diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 1f767fe9f..a751fc9c1 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -97,7 +97,7 @@ export function TopBar() { // inside its own 4rem, a 59 px inset left the bar a 5 px content box. > {/* Mobile logo */} - + HealthLog diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index bcf43539b..07a1b5fb3 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -46,13 +46,13 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline", }, size: { - default: "h-10 px-4 py-2 has-[>svg]:px-3", + default: "h-10 px-4 py-2 has-[>svg]:px-3 pointer-coarse:min-h-11", xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 pointer-coarse:min-h-11 pointer-coarse:min-w-11", lg: "h-11 rounded-md px-6 has-[>svg]:px-4", - icon: "size-10", + icon: "size-10 pointer-coarse:min-h-11 pointer-coarse:min-w-11", "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8", + "icon-sm": "size-8 pointer-coarse:min-h-11 pointer-coarse:min-w-11", "icon-lg": "size-11", }, }, diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 8cdc30378..4aadf57fb 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -47,8 +47,10 @@ function Tabs({ // Padding sits at `p-1` (4 px) so the 3 px focus-visible ring // (`focus-visible:ring-[3px]`) on triggers stays inside the // `overflow-y-hidden` clamp without clipping the bottom of the ring. +// Under a coarse pointer the strip sizes to its triggers, which take the +// 44 px touch floor; a mouse keeps the compact 36 px strip. const tabsListVariants = cva( - "rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden touch-pan-x scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible", + "rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden touch-pan-x scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible", { variants: { variant: { @@ -86,7 +88,7 @@ function TabsTrigger({