From d13dff93f2a14b23500179497d8a096b843a49bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 22:55:31 +0200 Subject: [PATCH 1/2] fix(ui): pin dialog footer so the primary action stays in view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop branch of ResponsiveSheet rendered DialogContent as a single overflow-y-auto grid, so a tall form — or one made tall by browser zoom, display scaling, or a longer-language label — scrolled its header, body, and footer together and could push the primary button below the fold. The branch now runs as a flex column capped at max-h-[calc(100dvh-2rem)], holds the header and footer shrink-0, and scrolls only the body, matching the bottom-sheet branch that already pinned its footer. Every form mounted through the shared surface inherits the fix. Add a desktop end-to-end guard at a short viewport asserting the body scrolls and the footer's bottom edge stays within the viewport. --- e2e/v1427-responsive-sheet.spec.ts | 44 +++++++++++++++++++++ src/components/ui/responsive-sheet.tsx | 53 +++++++++++++++++++------- 2 files changed, 83 insertions(+), 14 deletions(-) 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/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} From f3ffafaae12cf7c3614db8edf373b7532fe66721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 22:55:37 +0200 Subject: [PATCH 2/2] chore(release): v1.21.1 Patch release. Pins the action row in the centred dialog so the primary button stays reachable without scrolling under long forms, browser zoom, or display scaling. No migrations, no breaking changes. --- CHANGELOG.md | 8 ++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) 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/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/*`