diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9d23030..18a987122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [1.21.1] — 2026-06-26 — Dialog footer reachability + +A patch release. No migrations, no breaking changes. + +### Fixed + +- Pop-up forms now scroll only their body on desktop, keeping the action row in view. A tall form — or one made tall by browser zoom, display scaling, or a longer-language label — could push the primary button below the fold and force a scroll to reach it; the centred dialog now matches the bottom sheet, which already pinned its footer. Every form the app mounts through the shared sheet surface inherits the fix. + ## [1.21.0] — 2026-06-26 — Date-format preference, a far more connected Coach, and broad correctness work A feature release. The Coach reaches every data domain on demand, surfaces the cross-metric patterns the analytics tier already discovers, opens in context from any screen, and speaks with a warmer, forward-looking voice on one shared set of safety thresholds. Dates render in your chosen format everywhere. Two additive migrations (`0193` rollup x-rescale, `0192` date format); no breaking changes. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5c035b17b..e1f0095fd 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.21.0 + version: 1.21.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/v1427-responsive-sheet.spec.ts b/e2e/v1427-responsive-sheet.spec.ts index 0670342de..1e7a35b86 100644 --- a/e2e/v1427-responsive-sheet.spec.ts +++ b/e2e/v1427-responsive-sheet.spec.ts @@ -87,4 +87,48 @@ test.describe("v1.4.27 — ResponsiveSheet branch", () => { await expect(content).toBeVisible({ timeout: 10_000 }); await expect(content).toHaveAttribute("data-variant", "dialog"); }); + + // v1.21.1 — the desktop Dialog branch must scroll only its body and + // keep the footer (the primary action) inside the viewport when the + // form is taller than the available height. A short viewport stands in + // for the real-world triggers — browser zoom, OS display scaling, long + // locale strings — that push a form past `max-h-[calc(100dvh-2rem)]`. + test("Desktop Chrome: footer stays in viewport when the form overflows", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "desktop-only branch", + ); + + // Deliberately short so the Add-Measurement form exceeds the dialog + // height cap and forces the body to scroll. + await page.setViewportSize({ width: 1280, height: 480 }); + + await page.goto("/measurements?add=WEIGHT", { + waitUntil: "domcontentloaded", + }); + + const content = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(content).toBeVisible({ timeout: 10_000 }); + await expect(content).toHaveAttribute("data-variant", "dialog"); + + // The body owns the scroll — its content is taller than its box. + const body = page.locator('[data-slot="responsive-sheet-body"]'); + await expect(body).toBeVisible(); + const bodyScrolls = await body.evaluate( + (el) => el.scrollHeight > el.clientHeight + 1, + ); + expect(bodyScrolls).toBe(true); + + // The footer (primary action) is reachable without scrolling: its + // bottom edge sits within the viewport. + const footer = page.locator('[data-slot="responsive-sheet-footer"]'); + await expect(footer).toBeVisible(); + const box = await footer.boundingBox(); + expect(box).not.toBeNull(); + const viewport = page.viewportSize(); + expect(viewport).not.toBeNull(); + expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1); + }); }); diff --git a/package.json b/package.json index 3fec1e7a8..63c485a5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.21.0", + "version": "1.21.1", "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 b580222e5..5973ae98b 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.21.0"; + /* @sw-version-fallback */ "v1.21.1"; 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/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index 0099f2a85..e212b3358 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -53,10 +53,9 @@ export interface ResponsiveSheetProps { * child and breaks that responsive stacking. This split is a * decision, not drift — don't unify the two branches. * - * The Sheet branch sticky-pins the footer to the - * bottom of the bottom-sheet so the primary CTA stays reachable - * once the body scrolls; the Dialog branch lets the footer flow - * normally beneath the body. + * Both branches keep the footer reachable once the body scrolls: + * the Sheet branch sticky-pins it to the bottom of the bottom-sheet, + * and the Dialog branch holds it `shrink-0` below the scrolling body. */ footer?: React.ReactNode; /** Class name applied to the content surface (sheet / dialog). */ @@ -100,12 +99,15 @@ const CONTENT_WIDTH_CLASS: Record< * centred `` on tablet/desktop. Picks the breakpoint via * the `useIsMobile()` hook (Tailwind `md` — 768 px). * - * On the Sheet branch we cap the content at `90dvh`, scroll the - * body on overflow, and sticky-pin the footer so Save / Cancel - * stay reachable when the keyboard pushes the bottom of the sheet - * up. The Dialog branch consumes the primitive's `max-h-[calc(100dvh-2rem)]` - * default so long forms scroll inside the dialog instead of - * spilling off-screen on tablet portrait. + * Both branches use the same layout contract: cap the surface + * height, scroll ONLY the body on overflow, and keep the footer + * reachable. The Sheet branch caps at `90dvh` and sticky-pins the + * footer so Save / Cancel stay reachable when the keyboard pushes + * the bottom of the sheet up. The Dialog branch caps at the + * primitive's `max-h-[calc(100dvh-2rem)]`, runs as a flex column, + * and pins its header + footer (`shrink-0`) so the body — not the + * whole grid — is what scrolls; the primary action never slides + * below the fold under long forms, browser zoom, or OS scaling. * * Consumers swap `` → `` and feed the * existing form markup through `children` / `footer`. The rest of @@ -194,7 +196,21 @@ export function ResponsiveSheet({ showCloseButton={showCloseButton} data-slot="responsive-sheet-content" data-variant="dialog" - className={cn(CONTENT_WIDTH_CLASS[contentWidth], className)} + // v1.21.1 — flip the desktop Dialog from the primitive's single + // `overflow-y-auto` grid (where header + body + footer all scroll + // together, so the primary CTA can slide below the fold once the + // form, a long locale string, browser zoom, or OS display scaling + // pushes the content past `max-h-[calc(100dvh-2rem)]`) to a flex + // column that scrolls ONLY the body. This brings the Dialog branch + // to parity with the Sheet branch, which already pins its footer. + // The header + footer stay shrink-0 so the body absorbs every + // height shortfall and the action row is always reachable without + // scrolling. `overflow-hidden` makes the body the lone scroll port. + className={cn( + "flex flex-col overflow-hidden", + CONTENT_WIDTH_CLASS[contentWidth], + className, + )} > {hideHeader ? ( @@ -204,7 +220,10 @@ export function ResponsiveSheet({ ) : null} ) : ( - + {title} {description ? ( {description} @@ -213,12 +232,18 @@ export function ResponsiveSheet({ )}
{children}
{footer ? ( - + {footer} ) : null}