From ed5cadf25d84e48c47e57fdc82d0040ca456fd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 4 Jul 2026 15:51:46 +0200 Subject: [PATCH 1/5] fix(layout): start the sidebar on the icon rail at tablet widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 256 px expanded sidebar next to a 768 px viewport leaves the content a 512 px column — an iPad held upright rendered every page squeezed. With no stored preference, md–lg widths now start collapsed to the icon rail; a manual toggle persists and wins on every later visit. --- src/components/layout/sidebar-nav.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index e99c069ee..4abb16fbf 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -255,7 +255,17 @@ export function SidebarNav() { const [collapsed, setCollapsed] = useState(() => { if (typeof window === "undefined") return false; try { - return localStorage.getItem(STORAGE_KEY) === "true"; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored !== null) return stored === "true"; + } catch { + return false; + } + // No stored preference: tablet widths (md–lg, e.g. an iPad held + // upright) start on the icon rail so the content column keeps its + // room — a 256 px sidebar squeezes a 768 px viewport down to 512 px. + // A manual toggle persists and wins on every later visit. + try { + return window.matchMedia("(max-width: 1023.98px)").matches; } catch { return false; } From c10288d61ba33179f83fe9fbee621bf694e750f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 4 Jul 2026 15:51:46 +0200 Subject: [PATCH 2/5] fix(layout): let the content column shrink below its children's min width Without min-w-0 the shell's flex column adopted the measurements table's intrinsic min width, widening main past the viewport and putting the whole page on a horizontal scrollbar at tablet widths. Wide children scroll inside their own overflow-x-auto containers. --- src/components/layout/auth-shell.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From 2a5cfefc3a5999de9df65e95f0312e69ccecc629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 4 Jul 2026 15:51:47 +0200 Subject: [PATCH 3/5] test(e2e): guard iPad-portrait widths against overflow and a squeezed column --- e2e/ipad-viewport.spec.ts | 70 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 e2e/ipad-viewport.spec.ts 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); + }); + } +}); From fe61f34fca93534078bedbec110bcec1fa97e285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 4 Jul 2026 15:51:47 +0200 Subject: [PATCH 4/5] =?UTF-8?q?chore(release):=20v1.27.1=20=E2=80=94=20iPa?= =?UTF-8?q?d=20layout=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) 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/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", From 109866b5d073306902b0d8b92dd7256b665f5c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 4 Jul 2026 16:13:15 +0200 Subject: [PATCH 5/5] fix(layout): apply the sidebar viewport default after hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading matchMedia (or localStorage) inside the initial render branches the hydration render away from the SSR markup — the sidebar is only CSS-hidden below md and still hydrates its DOM there, so the phone viewport tripped React #418 in the dashboard console guard. The collapsed state now gates on the mounted probe like the module filter above it: SSR and hydration paint the expanded shell, the stored pref or tablet default applies right after. useIsMobile learns the lg cut for the tablet probe. --- src/components/layout/sidebar-nav.tsx | 47 ++++++++++++++------------- src/hooks/use-is-mobile.ts | 11 +++++-- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 4abb16fbf..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,35 +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 { const stored = localStorage.getItem(STORAGE_KEY); - if (stored !== null) return stored === "true"; + return stored === null ? null : stored === "true"; } catch { - return false; - } - // No stored preference: tablet widths (md–lg, e.g. an iPad held - // upright) start on the icon rail so the content column keeps its - // room — a 256 px sidebar squeezes a 768 px viewport down to 512 px. - // A manual toggle persists and wins on every later visit. - try { - return window.matchMedia("(max-width: 1023.98px)").matches; - } 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),