diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a669c910..f340e7822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +## [1.28.3] — 2026-07-09 — Consistent spacing and alignment + +A consistency pass across the app, driven by a layout audit. Insight cards now +share one left edge — headings and body text line up instead of drifting from +card to card (worst on mobile). Settings and admin pages no longer scroll past +their content into empty space. On mobile, Settings and Notifications live only +in the account menu instead of being duplicated in the overflow menu. The +dashboard greeting keeps its action beside the text rather than pushing it onto +its own line. Preventive-care, cycle, and onboarding cards adopt the shared card +anatomy. The spacing and alignment rules behind these are written down so they +hold going forward. + +Also folds in the document PDF rasterization runtime: the image renderer now +loads in the container, so a scanned PDF is read on an image-only AI provider +(previously it quietly fell back to text-only). + ## [1.28.2] — 2026-07-09 — Automatic AI document reading (opt-in) One opt-in in AI settings: read every uploaded document automatically with your diff --git a/Dockerfile b/Dockerfile index a883e6c82..7d3bcd895 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,6 +81,20 @@ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the +# native binary's package into the standalone tree but NOT the pnpm symlink that +# @napi-rs/canvas's loader resolves it through, so at runtime it fails with +# "Failed to load native binding" and rasterization silently degrades. Copy the +# prebuilt .node into the canvas package dir itself so the loader's built-in +# local-file fallback (`require('./skia..node')`) resolves it. Per-arch +# buildx installs only the matching musl triple, so the find is arch-correct. +RUN CANVAS_DIR="$(find ./node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas@*/node_modules/@napi-rs/canvas' 2>/dev/null | head -1)"; \ + NODE_BIN="$(find ./node_modules/.pnpm -maxdepth 5 -name 'skia.linux-*-musl.node' 2>/dev/null | head -1)"; \ + if [ -n "$CANVAS_DIR" ] && [ -n "$NODE_BIN" ]; then \ + cp "$NODE_BIN" "$CANVAS_DIR/" && chown nextjs:nodejs "$CANVAS_DIR/$(basename "$NODE_BIN")" && \ + echo "canvas native binding staged: $(basename "$NODE_BIN") -> $CANVAS_DIR"; \ + else echo "WARN: canvas native binding not found; PDF rasterization will degrade to local text"; fi + # Copy Prisma for migrations (schema, migration SQL, config, engines) COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 870b9d6b6..2a30750d1 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.28.2 + version: 1.28.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index e9dd554d4..1b11d2cd6 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -13,18 +13,28 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * the shell's own budget and reopens the dark-band-below-the-last-card * bug. * - * The assertion: `main.scrollHeight` ≤ bottom edge of the lowest content - * element + the shell-owned padding + tolerance. Runs across the - * settings routes (short hub, long sortable-list subpages, long form - * section) at desktop AND phone-shaped viewports, because the two shell + * The assertion: `main.scrollHeight` ≤ bottom edge of the lowest real + * content element + the shell-owned padding + tolerance. "Real content" + * is measured against each element's CONTENT-box bottom, not its padding- + * box: a sub-shell column that re-declares its own bottom gutter (`pb-*`) + * no longer folds that gutter into `contentBottom`, so a redundant reserve + * FAILS the assertion instead of being absorbed by it. Runs across both + * two-column sub-shells — Settings AND Admin (they share the grid-floor + * source) — at desktop AND phone-shaped viewports, because the two shell * paddings differ per breakpoint. */ const ROUTES = [ + // Settings sub-shell — short hub, long sortable-list subpages, long form. "/settings/layout", "/settings/layout/dashboard", "/settings/layout/insights", "/settings/account", "/settings/notifications", + // Admin sub-shell — short overview + a longer list page. Guards that the + // pre-#154 column reserve stays retired on every admin breakpoint. + "/admin", + "/admin/login-overview", + "/admin/system-status", ] as const; const VIEWPORTS = [ @@ -32,7 +42,7 @@ const VIEWPORTS = [ { name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16 ] as const; -test.describe("settings vertical over-scroll guard", () => { +test.describe("settings + admin vertical over-scroll guard", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(({}, testInfo) => { @@ -56,11 +66,17 @@ test.describe("settings vertical over-scroll guard", () => { if (!main) return null; const wrapper = main.firstElementChild as HTMLElement | null; if (!wrapper) return null; - // Lowest content edge: max bottom over the wrapper's visible, - // non-fixed descendants, in the scroll container's coordinate - // space. Elements inside nested scroll containers are clipped - // by their own overflow and never add page scroll height, so - // skip anything whose scrollable ancestor is not `main`. + // Lowest content edge: max CONTENT-box bottom over the wrapper's + // visible, non-fixed descendants, in the scroll container's + // coordinate space. Using the content box (padding-box bottom + // minus the element's own bottom padding + border) means a + // layout column's own `pb-*` gutter is NOT counted — its last + // real child (a card) is still measured through the column's + // content box, so a redundant sub-shell bottom gutter shows up + // as pure over-scroll instead of inflating the allowed budget. + // Elements inside nested scroll containers are clipped by their + // own overflow and never add page scroll height, so skip + // anything whose scrollable ancestor is not `main`. let maxBottom = 0; const mainTop = main.getBoundingClientRect().top; for (const el of wrapper.querySelectorAll("*")) { @@ -84,7 +100,10 @@ test.describe("settings vertical over-scroll guard", () => { p = p.parentElement; } if (inner) continue; - const bottom = rect.bottom + main.scrollTop - mainTop; + const padBottom = parseFloat(cs.paddingBottom) || 0; + const borderBottom = parseFloat(cs.borderBottomWidth) || 0; + const bottom = + rect.bottom - padBottom - borderBottom + main.scrollTop - mainTop; if (bottom > maxBottom) maxBottom = bottom; } return { diff --git a/package.json b/package.json index 783cd92c6..e1d16a182 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.2", + "version": "1.28.3", "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 b47ec4239..fbed073e5 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.28.2"; + /* @sw-version-fallback */ "v1.28.3"; 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/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 8f3935573..15b3ea36f 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -37,6 +37,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; +import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs"; @@ -304,7 +305,12 @@ export function AdminShell({ active, children }: AdminShellProps) { col 1, cards in row 2 / col 2. The nav's first item starts the same grid row as the first card, so they align by construction across locales and however the heading wraps — no fixed-height spacer. */} -
+
{/* Heading — desktop only (mobile renders it above the strip). */}
{headingBlock} @@ -366,12 +372,14 @@ export function AdminShell({ active, children }: AdminShellProps) { - {/* Main column (cards) — row 2 / col 2. Same - `min-h-[calc(100dvh-12rem)]` reserve as `` - so navigating between admin sub-pages (e.g. system-status - → login-overview) does not jump the page height while the - new section is still fetching. */} -
+ {/* Main column (cards) — row 2 / col 2, the grid's `1fr` row, so it + stretches to fill the shared `SUB_SHELL_GRID_FLOOR` on the grid + (matches ``) without over-shooting. The loading-jump + floor lives on the GRID, not here: a column-level + `min-h-[calc(100dvh-12rem)]` reserve (the pre-#154 shape this used + to carry) over-reserves on short sections and on mobile, scrolling + the page into a dark band below the last card. */} +
{children}
diff --git a/src/components/cycle/cycle-insight-summary-card.tsx b/src/components/cycle/cycle-insight-summary-card.tsx index a143755b4..1fd2c4f48 100644 --- a/src/components/cycle/cycle-insight-summary-card.tsx +++ b/src/components/cycle/cycle-insight-summary-card.tsx @@ -114,7 +114,7 @@ export function CycleInsightSummaryCard() { data-slot="cycle-insight-summary" data-phase={phase ?? "none"} style={{ "--tile-hue": hue } as React.CSSProperties} - className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5" + className="wellness-tile wellness-tile-rise rounded-xl p-4 md:p-6" > {/* Zone 1 — the current phase / cycle-day read. */}
diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index fe65f734e..c857f1779 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -109,7 +109,7 @@ export function CycleRingTile({ className }: { className?: string }) { // per-phase hue arc inside the ring is the only saturated thing. style={{ "--tile-hue": hue } as React.CSSProperties} className={cn( - "wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none", + "wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-4 focus-visible:ring-2 focus-visible:outline-none md:p-6", play && "wellness-tile-rise", className, )} diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index a085ffb33..0d7b7d450 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -219,13 +219,13 @@ export function CycleView() { : undefined } className={cn( - "wellness-tile flex flex-col items-center gap-3 rounded-xl px-5 py-5", + "wellness-tile flex flex-col items-center gap-3 rounded-xl p-4 md:p-6", play && "wellness-tile-rise", )} > {loading ? (
- +
) : calendarError ? (
@@ -347,7 +347,7 @@ export function CycleView() { className="text-muted-foreground bg-muted/40 mb-3 flex items-start gap-2 rounded-lg p-3 text-sm" >