diff --git a/CHANGELOG.md b/CHANGELOG.md index f8846a835..3a6662430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [1.27.1] — 2026-07-04 — iPad layout fixes + +### Fixed + +- On tablet-width screens — an iPad held upright — the app now starts with the compact icon sidebar, so the content keeps the width it needs instead of being squeezed into a narrow column next to the full navigation. Expanding the sidebar still works and the choice is remembered, as before. +- The measurements page no longer pushes the whole page into a horizontal scroll on tablet widths; the history table scrolls within its own frame instead. + ## [1.27.0] — 2026-07-03 — Fitbit and Pixel Watch through Google Health ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 73144fe66..d7acf6a57 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.27.0 + version: 1.27.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/ipad-viewport.spec.ts b/e2e/ipad-viewport.spec.ts new file mode 100644 index 000000000..c222d9404 --- /dev/null +++ b/e2e/ipad-viewport.spec.ts @@ -0,0 +1,70 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * iPad-width regression guard (portrait, 768×1024). + * + * Two invariants this viewport class kept losing: + * + * 1. No horizontal page overflow — the content column must shrink + * below its children's intrinsic min width (`min-w-0` on the + * shell column); wide children scroll inside their own + * `overflow-x-auto` containers instead of widening the page. + * 2. The content column keeps its room: with no stored sidebar + * preference, tablet widths start on the icon rail (w-16), so + * `
` gets ~704 px of the 768 — not the 512 px left over + * next to the expanded 256 px sidebar. + * + * Desktop project only — the assertions are viewport-driven via + * `setViewportSize`, and the Pixel-5 project covers the phone class. + */ +const ROUTES = ["/", "/measurements", "/settings/integrations"] as const; + +test.describe("iPad portrait layout (768x1024)", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven spec; desktop project only", + ); + }); + + for (const route of ROUTES) { + test(`no horizontal overflow and full-width content on ${route}`, async ({ + page, + }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + const dims = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + innerWidth: window.innerWidth, + mainWidth: + document + .querySelector("main") + ?.getBoundingClientRect() + .width.valueOf() ?? 0, + })); + + // 1 px tolerance for sub-pixel rounding, matching the Pixel-5 spec. + expect( + dims.scrollWidth, + `page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, + ).toBeLessThanOrEqual(dims.innerWidth + 1); + expect( + dims.bodyScrollWidth, + `body scrollWidth=${dims.bodyScrollWidth}`, + ).toBeLessThanOrEqual(dims.innerWidth + 1); + + // Icon rail (64 px) + content: main must keep ≥ 690 px of the 768. + expect( + Math.round(dims.mainWidth), + `main width=${dims.mainWidth}`, + ).toBeGreaterThanOrEqual(690); + }); + } +}); diff --git a/package.json b/package.json index 5413ad7e8..921a52b65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.0", + "version": "1.27.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/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 85a9a2121..829a742ce 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -268,7 +268,12 @@ export function AuthShell({
-
+ {/* `min-w-0` lets the content column shrink below its children's + intrinsic min width (e.g. the measurements table); without it + the column widens past the viewport next to the sidebar and + the whole page gains a horizontal scrollbar. Wide children + scroll inside their own `overflow-x-auto` containers. */} +
{/* v1.4.43 QoL (M5) — `` paints only when `navigator.onLine === false`. Sits above the maintainership diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index e99c069ee..e28186030 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -25,6 +25,7 @@ import { import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; +import { useIsMobile } from "@/hooks/use-is-mobile"; import { useMounted } from "@/hooks/use-mounted"; import { useTheme } from "@/components/providers"; import { useTranslations } from "@/lib/i18n/context"; @@ -252,25 +253,37 @@ export function SidebarNav() { () => visibleNavDestinations(user?.modules, mounted), [user?.modules, mounted], ); - const [collapsed, setCollapsed] = useState(() => { - if (typeof window === "undefined") return false; + // Collapsed = the user's stored choice, or — with no stored choice — a + // viewport default: tablet widths (md–lg, e.g. an iPad held upright) + // fall back to the icon rail so the content column keeps its room; a + // 256 px sidebar squeezes a 768 px viewport down to a 512 px column. + // Everything is gated on `mounted` (same pattern as the module filter + // above): SSR and the hydration render both paint the expanded shell, + // the stored pref / viewport default applies on the first client render + // after hydration settles. Branching earlier — localStorage or + // matchMedia inside the initial render — is a React #418 hydration + // mismatch, because the sidebar is only CSS-hidden below `md` and still + // hydrates its DOM there. + const tabletOrBelow = useIsMobile("lg"); + const [collapsedPref, setCollapsedPref] = useState(() => { + if (typeof window === "undefined") return null; try { - return localStorage.getItem(STORAGE_KEY) === "true"; + const stored = localStorage.getItem(STORAGE_KEY); + return stored === null ? null : stored === "true"; } catch { - return false; + return null; } }); + const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false; function toggleCollapsed() { - setCollapsed((prev) => { - const next = !prev; - try { - localStorage.setItem(STORAGE_KEY, String(next)); - } catch { - // Ignore storage errors - } - return next; - }); + const next = !collapsed; + setCollapsedPref(next); + try { + localStorage.setItem(STORAGE_KEY, String(next)); + } catch { + // Ignore storage errors + } } // v1.17.1 (F-1 residue) — the sidebar footer utility links derive from diff --git a/src/hooks/use-is-mobile.ts b/src/hooks/use-is-mobile.ts index ee3445dd3..0142cbb14 100644 --- a/src/hooks/use-is-mobile.ts +++ b/src/hooks/use-is-mobile.ts @@ -36,8 +36,13 @@ import { useSyncExternalStore } from "react"; * need a tighter cut (e.g. the Coach drawer, which flips to a * bottom-sheet only below `sm` / 640 px) pass an explicit breakpoint. */ -function getMediaQuery(breakpoint: "sm" | "md"): string { - const maxWidth = breakpoint === "sm" ? "639.98px" : "767.98px"; +function getMediaQuery(breakpoint: "sm" | "md" | "lg"): string { + const maxWidth = + breakpoint === "sm" + ? "639.98px" + : breakpoint === "md" + ? "767.98px" + : "1023.98px"; return `(max-width: ${maxWidth})`; } @@ -59,7 +64,7 @@ function getServerSnapshot(): boolean { return false; } -export function useIsMobile(breakpoint: "sm" | "md" = "md"): boolean { +export function useIsMobile(breakpoint: "sm" | "md" | "lg" = "md"): boolean { const query = getMediaQuery(breakpoint); return useSyncExternalStore( subscribe(query),