From 76af8680647e82bcc113700ed47408709506913e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:25:47 +0200 Subject: [PATCH 01/27] fix(day): step the open day from the URL, not from the last rendered day A step read the day the layer had last published, which reaches the shortcut and the header arrows through useSearchParams, a render and an effect. Next 16.4 lets that chain trail a replaceState by more than a key repeat, so two quick presses of ] both stepped from the same day and one was lost. stepDay now reads ?day= from window.location, which the step itself writes, and refuses a step past today on that value too. --- .../__tests__/day-layer-controller.test.ts | 41 ++++++++++++++++--- src/components/day/day-layer-controller.ts | 26 ++++++++++-- src/components/day/day-layer.tsx | 3 +- src/components/layout/global-shortcuts.tsx | 10 ++--- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/components/day/__tests__/day-layer-controller.test.ts b/src/components/day/__tests__/day-layer-controller.test.ts index 32eef666c..f950e9d2d 100644 --- a/src/components/day/__tests__/day-layer-controller.test.ts +++ b/src/components/day/__tests__/day-layer-controller.test.ts @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; * * - opening adds exactly one entry, so Back closes the layer; * - opening another day while one is open swaps it in place; - * - stepping to the neighbouring day never adds an entry, however often; + * - stepping to the neighbouring day never adds an entry, however often, + * starts from the day in the URL and never passes today; * - closing a day the layer opened goes Back (the next Back leaves the * page); closing a deep link only drops the parameter and stays. */ @@ -57,6 +58,8 @@ function installFakeBrowser(initialUrl: string) { let browser: ReturnType; +const TODAY = "2026-01-10"; + beforeEach(() => { browser = installFakeBrowser("/insights/blood-pressure?range=90"); }); @@ -88,18 +91,44 @@ describe("day layer history", () => { it("steps without adding history entries", async () => { const { openDay, stepDay } = await import("../day-layer-controller"); openDay("2026-01-03"); - stepDay("2026-01-03", -1); - stepDay("2026-01-02", -1); - stepDay("2026-01-01", 1); + stepDay(-1, TODAY); + stepDay(-1, TODAY); + stepDay(1, TODAY); expect(browser.length()).toBe(2); expect(browser.current()).toContain("day=2026-01-02"); }); + it("steps from the day in the URL, so quick steps in a row all count", async () => { + // Nothing republishes the open day between these calls, which is what a + // fast key repeat looks like while the layer is still rendering the + // previous step. Each step must still move from where the last one went. + const { openDay, stepDay } = await import("../day-layer-controller"); + openDay("2026-01-03"); + expect(stepDay(1, TODAY)).toBe("2026-01-04"); + expect(stepDay(1, TODAY)).toBe("2026-01-05"); + expect(browser.current()).toContain("day=2026-01-05"); + }); + + it("never steps past today, however often it is asked", async () => { + const { openDay, stepDay } = await import("../day-layer-controller"); + openDay("2026-01-09"); + expect(stepDay(1, TODAY)).toBe(TODAY); + expect(stepDay(1, TODAY)).toBeNull(); + expect(stepDay(1, TODAY)).toBeNull(); + expect(browser.current()).toContain(`day=${TODAY}`); + }); + + it("does nothing while no day is open", async () => { + const { stepDay } = await import("../day-layer-controller"); + expect(stepDay(-1, TODAY)).toBeNull(); + expect(browser.current()).toBe("/insights/blood-pressure?range=90"); + }); + it("closes a pushed day with Back, landing on the page without it", async () => { const { closeDay, openDay, stepDay } = await import("../day-layer-controller"); openDay("2026-01-03"); - stepDay("2026-01-03", 1); + stepDay(1, TODAY); closeDay(); expect(browser.current()).toBe("/insights/blood-pressure?range=90"); }); @@ -107,7 +136,7 @@ describe("day layer history", () => { it("closes a deep link in place: the parameter goes, the page stays", async () => { browser = installFakeBrowser("/?day=2026-01-03"); const { closeDay, stepDay } = await import("../day-layer-controller"); - stepDay("2026-01-03", -1); + stepDay(-1, TODAY); expect(browser.current()).toBe("/?day=2026-01-02"); closeDay(); expect(browser.current()).toBe("/"); diff --git a/src/components/day/day-layer-controller.ts b/src/components/day/day-layer-controller.ts index 1431ce594..28f5dd195 100644 --- a/src/components/day/day-layer-controller.ts +++ b/src/components/day/day-layer-controller.ts @@ -2,11 +2,12 @@ import { useSyncExternalStore } from "react"; -import type { DateKey } from "@/lib/day/contract"; +import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract"; import { dayHistoryState, historyEntryOwnedByLayer, + parseDayParam, shiftDateKey, withDayHref, } from "./day-url"; @@ -155,8 +156,27 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void { } } -/** Move the open day by `delta` calendar days, without a new history entry. */ -export function stepDay(from: DateKey, delta: number): DateKey { +/** + * Move the open day by `delta` calendar days, without a new history entry. + * Null, and nothing written, when no day is open or the step would pass + * `today`. + * + * The step starts from the day in the URL at the moment of the step, not + * from the day a caller last rendered. The rendered day reaches a caller + * through Next's `useSearchParams`, a render of the layer and its + * `publishOpenDay`, and Next 16.4 lets that chain trail a `replaceState` by + * more than a key repeat: two quick `]` presses then both stepped from the + * same day and one of them was lost. `window.location` is written by the + * step itself, so it is never behind. + */ +export function stepDay(delta: number, today: DateKey): DateKey | null { + if (typeof window === "undefined") return null; + const from = parseDayParam( + new URLSearchParams(window.location.search).get(DAY_QUERY_PARAM), + today, + ); + if (from === null) return null; + if (delta > 0 && from >= today) return null; const next = shiftDateKey(from, delta); jumpDay(next); return next; diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index 8c75f2ea3..7a6676e14 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -348,8 +348,7 @@ function DayLayer() { const onStep = useCallback( (delta: number) => { if (date === null) return; - if (delta > 0 && date >= today) return; - stepDay(date, delta); + stepDay(delta, today); }, [date, today], ); diff --git a/src/components/layout/global-shortcuts.tsx b/src/components/layout/global-shortcuts.tsx index 71eb14229..1e5953c65 100644 --- a/src/components/layout/global-shortcuts.tsx +++ b/src/components/layout/global-shortcuts.tsx @@ -192,12 +192,12 @@ export function GlobalShortcuts() { setShortcutsHelpOpen(true); return; case "day": { - const date = state.openDay; - if (date === null) return; + if (state.openDay === null) return; event.preventDefault(); - // No day after today: the layer refuses it, so the step does too. - if (action.delta > 0 && date >= state.today) return; - stepDay(date, action.delta); + // The step reads the day from the URL, which is ahead of + // `openDay` while a previous step is still rendering, and refuses + // a day after today itself. + stepDay(action.delta, state.today); return; } case "day-toggle": From 44c2973a64194fe41b089a3bdc1a6d6a202997ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:25:48 +0200 Subject: [PATCH 02/27] fix(deps): update next to 16.4.2 and nodemailer to 10.1.7 Next 16.4.2 carries the fixes for GHSA-673q-mjm6-r5xr and GHSA-2464-fhr4-w9xw (unauthenticated remote code execution) and GHSA-p3m5-59mp-jv5r (denial of service through Server Actions). eslint-config-next and @next/bundle-analyzer move with it, both pinned exactly. nodemailer 10.1.7 fixes the address-parser advisories. The 16.4.0 ignore in the Dependabot config is gone: the history journeys it was guarding pass on 16.4.2 with the day-step fix, and the note says what was measured. --- .github/dependabot.yml | 29 ++++---- package.json | 8 +-- pnpm-lock.yaml | 156 +++++++++++++++++------------------------ 3 files changed, 82 insertions(+), 111 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 95944b652..7d9db7c92 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,9 +14,10 @@ updates: # single bumps: patch and minor updates travel together and pass the # same checks once. Majors stay single so each gets its own review. # - # The Next.js line travels alone: while a 16.4 patch is still being - # re-measured, a red Next bump inside the group holds every harmless - # update in it red as well. + # The Next.js line travels alone. Its bumps are the ones that move + # URL state after history traversal (see the note on 16.4 below), and a + # red Next bump inside the group would hold every harmless update in + # it red as well. production-weekly: dependency-type: "production" update-types: @@ -32,19 +33,15 @@ updates: exclude-patterns: - "eslint-config-next" - "@next/bundle-analyzer" - ignore: - # Next 16.4.0 breaks URL state after history traversal: with a sheet - # opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes - # do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40 - # repeated runs on 16.4.0, 0 of 80 on 16.3.8 with everything else - # equal). 16.3.8 carries every published security fix, so the line - # waits for a 16.4 patch; the two packages that move with it wait too. - - dependency-name: "next" - versions: ["16.4.0"] - - dependency-name: "eslint-config-next" - versions: ["16.4.0"] - - dependency-name: "@next/bundle-analyzer" - versions: ["16.4.0"] + # No ignore for the Next.js line any more. 16.4.0 lost URL state after + # history traversal: with a sheet opened through `?doc=`, Back left it + # open, and `[` / `]` sometimes did not move `?day=` (3 of 40 repeated + # runs of three end-to-end journeys, 0 of 80 on 16.3.8). Measured on + # 16.4.2: the `?doc=` and dashboard journeys passed 80 of 80, the day keys + # still lost a step in 2 of 20, because a step started from the day the + # layer had last rendered and 16.4 lets that trail the URL. A step now + # starts from the URL itself; after that the three journeys passed 200 of + # 200 repeated runs and the day keys alone another 80 of 80. - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/package.json b/package.json index 15a5b2028..6e5cbd564 100644 --- a/package.json +++ b/package.json @@ -79,8 +79,8 @@ "jspdf-autotable": "^5.0.8", "lucide-react": "^1.56.0", "mmdb-lib": "^3.0.3", - "next": "16.3.8", - "nodemailer": "10.1.0", + "next": "16.4.2", + "nodemailer": "10.1.7", "otpauth": "^9.5.2", "p-limit": "^7.3.3", "pg": "^8.23.1", @@ -106,7 +106,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.13.0", - "@next/bundle-analyzer": "^16.3.8", + "@next/bundle-analyzer": "16.4.2", "@playwright/test": "^1.64.0", "@tailwindcss/postcss": "^4", "@tesseract.js-data/deu": "1.0.0", @@ -121,7 +121,7 @@ "@vitest/coverage-v8": "^5.0.4", "ajv": "^8.20.0", "eslint": "^9", - "eslint-config-next": "16.3.8", + "eslint-config-next": "16.4.2", "knip": "^6.41.0", "lefthook": "^2.2.2", "pdf-parse": "^2.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a51aab68..a33b8b3b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,11 +120,11 @@ importers: specifier: ^3.0.3 version: 3.0.3 next: - specifier: 16.3.8 - version: 16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 16.4.2 + version: 16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) nodemailer: - specifier: 10.1.0 - version: 10.1.0 + specifier: 10.1.7 + version: 10.1.7 otpauth: specifier: ^9.5.2 version: 9.5.2 @@ -196,8 +196,8 @@ importers: specifier: ^4.13.0 version: 4.13.0(playwright-core@1.64.0) '@next/bundle-analyzer': - specifier: ^16.3.8 - version: 16.3.8 + specifier: 16.4.2 + version: 16.4.2 '@playwright/test': specifier: ^1.64.0 version: 1.64.0 @@ -241,8 +241,8 @@ importers: specifier: ^9 version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0) eslint-config-next: - specifier: 16.3.8 - version: 16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + specifier: 16.4.2 + version: 16.4.2(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) knip: specifier: ^6.41.0 version: 6.41.0 @@ -1075,13 +1075,6 @@ packages: resolution: {integrity: sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==} engines: {node: '>= 10'} - '@napi-rs/wasm-runtime@1.2.4': - resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@napi-rs/wasm-runtime@1.2.5': resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1089,63 +1082,63 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/bundle-analyzer@16.3.8': - resolution: {integrity: sha512-8VBk5/pjANz7fxbQZrDwUMTAs598Vckb4PE7BSYrDIPthJskKCXEHOK+WEMgDD6X2xWlKjrXPo2r41N/G1azSw==} + '@next/bundle-analyzer@16.4.2': + resolution: {integrity: sha512-tpNTC/WZYsmmixg3MZfx5JUuTh+Sgryu4eKhe7leQRcXHabztqtTkPCQM1Bk1VsE0trZ3w7NUCC3Zf7SeIBVzg==} - '@next/env@16.3.8': - resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} + '@next/env@16.4.2': + resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==} - '@next/eslint-plugin-next@16.3.8': - resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==} + '@next/eslint-plugin-next@16.4.2': + resolution: {integrity: sha512-gCTWlw0e+Y6kajpFyKKgA195jUJCRhlzJE0lV7ufe8a+ghIsANWZ0/FiJwg6DuOG1+OvRbcZDcFBoRh3mNJx0g==} - '@next/swc-darwin-arm64@16.3.8': - resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} + '@next/swc-darwin-arm64@16.4.2': + resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.8': - resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==} + '@next/swc-darwin-x64@16.4.2': + resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.8': - resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==} + '@next/swc-linux-arm64-gnu@16.4.2': + resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.8': - resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==} + '@next/swc-linux-arm64-musl@16.4.2': + resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.8': - resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==} + '@next/swc-linux-x64-gnu@16.4.2': + resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.8': - resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==} + '@next/swc-linux-x64-musl@16.4.2': + resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.8': - resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==} + '@next/swc-win32-arm64-msvc@16.4.2': + resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.8': - resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==} + '@next/swc-win32-x64-msvc@16.4.2': + resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3348,11 +3341,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.26: - resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} - engines: {node: '>=6.0.0'} - hasBin: true - baseline-browser-mapping@2.11.27: resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} engines: {node: '>=6.0.0'} @@ -3459,9 +3447,6 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001814: - resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} - caniuse-lite@1.0.30001815: resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} @@ -3961,8 +3946,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@16.3.8: - resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==} + eslint-config-next@16.4.2: + resolution: {integrity: sha512-P33vjcETMXc+qBjpF77TeJHZLzk6cPcTy8KjpxLucYB3mCIFIORMcZ+cBXcAiIYjE5RM7jyknEEOwMNdnx/ADQ==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -5177,11 +5162,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.19: - resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5199,8 +5179,8 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - next@16.3.8: - resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==} + next@16.4.2: + resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5241,8 +5221,8 @@ packages: resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} engines: {node: '>=18'} - nodemailer@10.1.0: - resolution: {integrity: sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==} + nodemailer@10.1.7: + resolution: {integrity: sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==} engines: {node: '>=20.0.0'} non-error@0.1.0: @@ -7727,7 +7707,7 @@ snapshots: '@napi-rs/canvas-win32-arm64-msvc': 1.0.11 '@napi-rs/canvas-win32-x64-msvc': 1.0.11 - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 @@ -7748,44 +7728,44 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true - '@next/bundle-analyzer@16.3.8': + '@next/bundle-analyzer@16.4.2': dependencies: webpack-bundle-analyzer: 4.10.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@next/env@16.3.8': {} + '@next/env@16.4.2': {} - '@next/eslint-plugin-next@16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))': + '@next/eslint-plugin-next@16.4.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.8': + '@next/swc-darwin-arm64@16.4.2': optional: true - '@next/swc-darwin-x64@16.3.8': + '@next/swc-darwin-x64@16.4.2': optional: true - '@next/swc-linux-arm64-gnu@16.3.8': + '@next/swc-linux-arm64-gnu@16.4.2': optional: true - '@next/swc-linux-arm64-musl@16.3.8': + '@next/swc-linux-arm64-musl@16.4.2': optional: true - '@next/swc-linux-x64-gnu@16.3.8': + '@next/swc-linux-x64-gnu@16.4.2': optional: true - '@next/swc-linux-x64-musl@16.3.8': + '@next/swc-linux-x64-musl@16.4.2': optional: true - '@next/swc-win32-arm64-msvc@16.3.8': + '@next/swc-win32-arm64-msvc@16.4.2': optional: true - '@next/swc-win32-x64-msvc@16.3.8': + '@next/swc-win32-x64-msvc@16.4.2': optional: true '@noble/hashes@2.4.0': {} @@ -9625,7 +9605,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': @@ -9991,8 +9971,6 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.26: {} - baseline-browser-mapping@2.11.27: {} bcrypt-pbkdf@1.0.2: @@ -10120,8 +10098,6 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001814: {} - caniuse-lite@1.0.30001815: {} canvg@3.0.11: @@ -10644,9 +10620,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + eslint-config-next@16.4.2(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@next/eslint-plugin-next': 16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0)) + '@next/eslint-plugin-next': 16.4.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0)) eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0) eslint-import-resolver-node: 0.3.10(supports-color@7.2.0) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) @@ -11907,8 +11883,6 @@ snapshots: nanoid@3.3.18: {} - nanoid@3.3.19: {} - nanoid@3.3.20: {} napi-postinstall@0.3.4: {} @@ -11917,25 +11891,25 @@ snapshots: negotiator@1.0.0: {} - next@16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@next/env': 16.3.8 + '@next/env': 16.4.2 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.26 - caniuse-lite: 1.0.30001814 + baseline-browser-mapping: 2.11.27 + caniuse-lite: 1.0.30001815 postcss: 8.5.23 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.8 - '@next/swc-darwin-x64': 16.3.8 - '@next/swc-linux-arm64-gnu': 16.3.8 - '@next/swc-linux-arm64-musl': 16.3.8 - '@next/swc-linux-x64-gnu': 16.3.8 - '@next/swc-linux-x64-musl': 16.3.8 - '@next/swc-win32-arm64-msvc': 16.3.8 - '@next/swc-win32-x64-msvc': 16.3.8 + '@next/swc-darwin-arm64': 16.4.2 + '@next/swc-darwin-x64': 16.4.2 + '@next/swc-linux-arm64-gnu': 16.4.2 + '@next/swc-linux-arm64-musl': 16.4.2 + '@next/swc-linux-x64-gnu': 16.4.2 + '@next/swc-linux-x64-musl': 16.4.2 + '@next/swc-win32-arm64-msvc': 16.4.2 + '@next/swc-win32-x64-msvc': 16.4.2 '@opentelemetry/api': 1.9.1 '@playwright/test': 1.64.0 sharp: 0.35.5(@types/node@26.6.5) @@ -11959,7 +11933,7 @@ snapshots: node-releases@2.0.58: {} - nodemailer@10.1.0: {} + nodemailer@10.1.7: {} non-error@0.1.0: {} @@ -12300,7 +12274,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.19 + nanoid: 3.3.20 picocolors: 1.1.1 source-map-js: 1.2.2 From 1fa33f2aee8a7fa97b9e3bf7a13ee1ebdd95f15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:25:49 +0200 Subject: [PATCH 03/27] fix(security): turn the image optimizer off Nothing renders through next/image, so /_next/image only offered a route that fetches, decodes and re-encodes whatever local path a request names. With images.unoptimized Next answers it with 404 before reading a parameter. A guard fails when next/image is imported while the optimizer is off, or when the optimizer returns with SVG input allowed. --- next.config.ts | 10 ++ .../next-image-optimizer-guard.test.ts | 113 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/__tests__/next-image-optimizer-guard.test.ts diff --git a/next.config.ts b/next.config.ts index a844e2081..055b1dcf7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -35,6 +35,16 @@ const nextConfig: NextConfig = { // still covers the full program. typescript: { tsconfigPath: "tsconfig.build.json" }, poweredByHeader: false, + // The image optimizer is off. Nothing renders through `next/image`: logos, + // icons and avatars are plain `` elements served from `public/` or + // from an authenticated route. With the optimizer on, `/_next/image` still + // fetched, decoded and re-encoded whatever local path a request named, + // which is surface this app has no use for and where the framework has + // shipped critical advisories. Off, Next answers that route with 404 + // before it reads a single parameter. `next-image-optimizer-guard.test.ts` + // holds this: it fails when `next/image` is imported while the optimizer + // is off, and when the optimizer comes back with SVG input allowed. + images: { unoptimized: true }, // v1.4.38.4 — expose the package.json version to the client bundle // so the `` can compare the shell-baked version // against the live `/api/version` response and trigger a self-heal diff --git a/src/__tests__/next-image-optimizer-guard.test.ts b/src/__tests__/next-image-optimizer-guard.test.ts new file mode 100644 index 000000000..16f8a3124 --- /dev/null +++ b/src/__tests__/next-image-optimizer-guard.test.ts @@ -0,0 +1,113 @@ +/** + * The image optimizer stays off while nothing uses it. + * + * `next.config.ts` sets `images.unoptimized`, which makes Next answer + * `/_next/image` with 404 before it reads a parameter, so the route that + * fetches, decodes and re-encodes images is not reachable at all. Two ways + * to undo that quietly, and this pins both: + * + * 1. A component imports `next/image` (or `next/legacy/image`) while the + * optimizer is off. It would render, unoptimized, and the next person + * to notice the missing optimization would switch the optimizer back on + * without the review this flag deserves. Using `next/image` means + * revisiting this decision, so the import fails here first. + * 2. The optimizer comes back on with `dangerouslyAllowSVG`. An SVG is a + * document, not a picture, and the framework's own advisories have been + * about exactly that input. Re-enabling the optimizer is allowed; doing + * it with SVG input allowed is not. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + requireFloor, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const ROOT = process.cwd(); +const SRC = join(ROOT, "src"); + +type ImagesConfig = { + unoptimized?: boolean; + dangerouslyAllowSVG?: boolean; +}; + +async function imagesConfig(): Promise { + const mod = (await import("../../next.config")) as { + default: { images?: ImagesConfig }; + }; + return mod.default.images ?? {}; +} + +/** Matches any import of `specifier` (static, dynamic, `require`, re-export). */ +function importOf(specifier: string): RegExp { + const escaped = specifier.replace(/[/]/g, "\\/"); + return new RegExp( + `(?:\\bfrom\\s*|\\bimport\\s*\\(\\s*|\\brequire\\s*\\(\\s*|^\\s*import\\s+)["']${escaped}["']`, + "m", + ); +} + +const NEXT_IMAGE_IMPORT = importOf("next/(?:legacy/)?image"); + +/** Every non-generated, non-test source file under `src/` matching `re`. */ +function importers(re: RegExp): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((rel) => !rel.startsWith("generated/")) + .filter((rel) => !rel.includes("__tests__/")) + .filter((rel) => + re.test(stripComments(readFileSync(join(SRC, rel), "utf8"))), + ); +} + +describe("next/image optimizer", () => { + it("the import matcher recognises every form of the import", () => { + // The sweep below passes when it matches nothing, so the matcher has to + // be shown to match before an empty result means anything. + for (const line of [ + 'import Image from "next/image";', + "import Image from 'next/legacy/image';", + 'const Image = await import("next/image");', + 'const Image = require("next/image");', + 'import "next/image";', + 'export { default } from "next/image";', + ]) { + expect(NEXT_IMAGE_IMPORT.test(line), line).toBe(true); + } + for (const line of [ + "// `next/image` is wrong here", + 'const path = "/_next/image?url=x";', + 'import { foo } from "next/image-extra";', + ]) { + expect(NEXT_IMAGE_IMPORT.test(stripComments(line)), line).toBe(false); + } + }); + + it("the same sweep finds the imports of a sibling module", () => { + // An empty result for `next/image` is only worth something if the walk, + // the comment stripping and the matcher together do find real imports. + // `next/link` is imported the same way all over the tree. + requireFloor("next/link importers", importers(importOf("next/link")), 100); + }); + + it("is off, and then nothing imports next/image", async () => { + const images = await imagesConfig(); + if (images.unoptimized !== true) return; + expect(importers(NEXT_IMAGE_IMPORT)).toEqual([]); + }); + + it("is never on with SVG input allowed", async () => { + const images = await imagesConfig(); + if (images.unoptimized === true) return; + expect(images.dangerouslyAllowSVG ?? false).toBe(false); + }); + + it("is off today", async () => { + // The two cases above each skip on the other branch. This pins which + // branch the tree is on, so flipping it is a visible change to this + // file and not a silent switch between the two. + expect((await imagesConfig()).unoptimized).toBe(true); + }); +}); From 5902401ab8d95907186b6d1837ba1cc9c95514f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:25:49 +0200 Subject: [PATCH 04/27] chore(release): v1.44.1 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0f73c02..d7d276837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [1.44.1] — 2026-10-22 + +### Security + +- **Next.js 16.4.2.** The framework fixed two critical advisories that + allow unauthenticated remote code execution, one in `use cache` functions + that return typed arrays (GHSA-673q-mjm6-r5xr) and one in the image + optimizer when it handles SVG (GHSA-2464-fhr4-w9xw), and a high-severity + denial of service through Server Actions (GHSA-p3m5-59mp-jv5r). HealthLog + uses neither `use cache` nor the image component, and it defines no Server + Actions and refuses requests that call one. Every instance runs the same + framework, though, so it updates anyway. +- **The image optimizer is off.** Nothing in HealthLog renders through it, + so `/_next/image` now answers 404 instead of processing whatever image a + request names. Icons and logos load as before. +- **nodemailer 10.1.7** fixes several advisories in how email addresses are + parsed (GHSA-8c8m-p3m8-cjg8 among them). + +### Fixed + +- **`[` and `]` move the open day on every press.** Pressed quickly in a + row, one press could be lost, because a step started from the day still + on screen rather than the one the previous press had already opened. + +### Upgrade notes + +- None. Pull the new image and restart. + ## [1.44.0] — 2026-10-22 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6cc0471ec..da8fbac66 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.44.0 + version: 1.44.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 6e5cbd564..5f044d763 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.44.0", + "version": "1.44.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 58b5aebf1..9d6ebe388 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.44.0"; + /* @sw-version-fallback */ "v1.44.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/*` From 1cc1cda7aae04d93ef732057f3b4a879b9c38c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 10:20:28 +0200 Subject: [PATCH 05/27] feat(ui): make every bottom sheet draggable One bottom sheet on top of the Radix dialog, so focus, Escape, the backdrop, aria and the page scroll lock stay where they were. The sheet follows a touch with a transform, the backdrop lightens with it, and a release past a quarter of the height or a flick down closes it; anything less springs back. Grip and header always drag; content only from its very top, and sideways gestures never count. Every `side="bottom"` sheet and ResponsiveSheet on a phone render through it: the More sheet, the capture and form sheets, the Coach drawer on a phone and the day. The day opens on the new preview step and pulls up to 90 dvh, its footer held on the bottom edge. Focus now returns to whatever opened a sheet, not only to a Radix trigger. The command palette fills the window and keeps no grip or drag. --- docs/contributing/ui-standards.md | 1 + src/__tests__/pwa-mobile-shell-guard.test.ts | 13 +- src/app/globals.css | 31 ++ .../command-palette/command-palette.tsx | 3 + src/components/day/day-layer.tsx | 81 +-- src/components/day/day-view.tsx | 19 +- src/components/layout/bottom-nav.tsx | 6 +- .../ui/__tests__/bottom-sheet.test.tsx | 151 ++++++ src/components/ui/bottom-sheet.tsx | 289 ++++++++++ src/components/ui/responsive-sheet.tsx | 4 +- src/components/ui/sheet.tsx | 55 +- src/hooks/__tests__/use-sheet-drag.test.ts | 373 +++++++++++++ src/hooks/use-sheet-drag.ts | 497 ++++++++++++++++++ src/lib/__tests__/sheet-gesture.test.ts | 239 +++++++++ src/lib/sheet-gesture.ts | 186 +++++++ 15 files changed, 1855 insertions(+), 93 deletions(-) create mode 100644 src/components/ui/__tests__/bottom-sheet.test.tsx create mode 100644 src/components/ui/bottom-sheet.tsx create mode 100644 src/hooks/__tests__/use-sheet-drag.test.ts create mode 100644 src/hooks/use-sheet-drag.ts create mode 100644 src/lib/__tests__/sheet-gesture.test.ts create mode 100644 src/lib/sheet-gesture.ts diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md index d0c0f576e..ee32c5dd8 100644 --- a/docs/contributing/ui-standards.md +++ b/docs/contributing/ui-standards.md @@ -98,6 +98,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s | Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) | | Honest no-data state | `EmptyState` | reusing error copy: a failed query must never fall through to "no data yet" | | Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) | +| A sheet from the bottom edge (phones) | `` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet | | Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) | | Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined ` - ) : null; - return ( } side={bottom ? "bottom" : "right"} + preview={bottom ? "58dvh" : undefined} + previewLabels={ + bottom + ? { expand: t("day.sheetExpand"), shrink: t("day.sheetShrink") } + : undefined + } showCloseButton={false} data-slot="day-panel" data-shell={shell} - data-full={bottom && full ? "true" : undefined} onOpenAutoFocus={(event) => { event.preventDefault(); focusTitle(); @@ -730,30 +693,18 @@ function DaySheet({ if (owner && owner !== panelRef.current) event.preventDefault(); }} className={cn( - // Quicker than the primitive's 500 ms, which read as lag on a - // layer opened from a tap; no slide at all under reduced motion. - "bg-card gap-0 p-0 data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none", + "bg-card gap-0 p-0", bottom - ? cn( - "rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none", - // Full height leaves a strip of the page above the - // sheet, and never less than the status bar of an - // installed app plus a margin: a 2.5rem strip put the - // handle under a 59 px Dynamic Island. - full - ? "h-[calc(100dvh-max(2.5rem,env(safe-area-inset-top,0px)+0.75rem))]" - : "h-[58dvh]", - ) - : "w-105 max-w-[90vw] sm:max-w-105", + ? "rounded-t-2xl" + : // Quicker than the primitive's 500 ms, which read as lag on a + // layer opened from a tap; no slide at all under reduced motion. + "w-105 max-w-[90vw] data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none sm:max-w-105", )} > {t("day.description")} - {render({ - above: grabber, - headerClassName: bottom ? undefined : "py-3", - })} + {render({ headerClassName: bottom ? undefined : "py-3" })} ); diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index 320486873..8b33287ff 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -75,8 +75,6 @@ export interface DayViewProps { }>; titleId: string; titleRef?: Ref; - /** Shell chrome above the header (the bottom sheet's grabber). */ - above?: ReactNode; /** Classes for the header row, which the docked shell aligns to the top bar. */ headerClassName?: string; } @@ -103,7 +101,6 @@ export function DayView({ Title, titleId, titleRef, - above, headerClassName, }: DayViewProps) { const { t } = useTranslations(); @@ -162,16 +159,20 @@ export function DayView({
- {above}
{timelineOn && !onTimeline ? ( diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index cffb18ea7..dbb11755a 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -274,7 +274,9 @@ export function BottomNav() { // The sheet auto-sizes to its content; padding-bottom respects // the iOS safe area so the inner buttons stay reachable above // the home-indicator on iPhone-X-class devices. - className="shell-desktop:hidden mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]" + // `gap-0`: the grip sits flush above the header, and the grid + // takes the header's old 16 px gap as its own top padding. + className="shell-desktop:hidden mx-auto max-w-md gap-0 rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]" data-testid="bottom-nav-more-sheet" > @@ -286,7 +288,7 @@ export function BottomNav() { (de "Benachrichtigungen") then have room to break onto a clean second line instead of clipping the last character at the tile edge. */} -
+
{moreHub.map((item) => { const isActive = isActiveLink(item.href); return ( diff --git a/src/components/ui/__tests__/bottom-sheet.test.tsx b/src/components/ui/__tests__/bottom-sheet.test.tsx new file mode 100644 index 000000000..68baef3cb --- /dev/null +++ b/src/components/ui/__tests__/bottom-sheet.test.tsx @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; + +/** + * The bottom sheet's anatomy, rendered statically: the grip, the 44 px X on + * the title row, the preview step's wiring, and that every bottom sheet + * (``) takes this path while the other sides do + * not. Radix portals do not render on the server, so its Dialog parts are + * passed through as plain elements; what Radix itself does (focus, Escape, + * the backdrop) and the drag in a browser are `e2e/bottom-sheet.spec.ts`. + */ + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +vi.mock("radix-ui", () => { + type P = { children?: ReactNode; [key: string]: unknown }; + const strip = ({ asChild: _asChild, ...rest }: P) => rest; + return { + Dialog: { + Root: ({ children }: P) => <>{children}, + Portal: ({ children }: P) => <>{children}, + Overlay: (props: P) =>
, + Content: ({ children, ...props }: P) => ( +
+ {children} +
+ ), + Close: ({ children, ...props }: P) => ( + + ), + Title: ({ children }: P) =>

{children}

, + Description: ({ children }: P) =>

{children}

, + Trigger: ({ children }: P) => <>{children}, + }, + }; +}); + +const { BottomSheetContent, BOTTOM_SHEET_CLASS } = + await import("../bottom-sheet"); +const { SheetContent } = await import("../sheet"); + +function attr(html: string, slot: string): string { + const match = new RegExp(`<[^>]*data-slot="${slot}"[^>]*>`).exec(html); + return match?.[0] ?? ""; +} + +describe("", () => { + it("draws a decorative grip and a 44 px X on the title row", () => { + const html = renderToStaticMarkup( + body, + ); + const grip = attr(html, "sheet-grip"); + expect(grip).toContain('aria-hidden="true"'); + expect(grip).toContain("h-6"); + expect(grip).toContain("touch-none"); + expect(html).toContain("h-1.25 w-9 rounded-full"); + const close = attr(html, "sheet-close-button"); + expect(close).toContain("size-11"); + expect(close).toContain("top-7.5"); + expect(html).toContain("common.close"); + // The swipe closes through a Radix Close of its own, out of reach. + const swipe = attr(html, "sheet-swipe-close"); + expect(swipe).toContain("hidden"); + expect(swipe).toContain('tabindex="-1"'); + }); + + it("keeps its backdrop out of touch panning", () => { + const html = renderToStaticMarkup( + body, + ); + expect(attr(html, "sheet-overlay")).toContain("touch-none"); + }); + + it("drops the grip, the drag and the X offset when it is not draggable", () => { + const html = renderToStaticMarkup( + body, + ); + expect(html).not.toContain('data-slot="sheet-grip"'); + expect(html).not.toContain('data-slot="sheet-swipe-close"'); + expect(attr(html, "sheet-close-button")).toContain("top-3"); + }); + + it("opens a preview sheet on its preview step, with a grip that expands it", () => { + const html = renderToStaticMarkup( + + body + , + ); + const content = attr(html, "sheet-content"); + expect(content).toContain('data-sheet-snap="preview"'); + expect(content).toContain("--sheet-height:90dvh"); + expect(content).toContain("--sheet-preview-height:58dvh"); + expect(content).toContain("h-(--sheet-height)"); + const grip = attr(html, "sheet-grip"); + expect(grip).toMatch(/^ + ); + } + return ( + + ); +} + +export function BottomSheetContent({ + className, + children, + showCloseButton = true, + preview, + previewLabels, + draggable = true, + ref, + style, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onClickCapture, + onOpenAutoFocus, + onCloseAutoFocus, + ...props +}: BottomSheetContentProps) { + const { t } = useTranslations(); + const contentRef = React.useRef(null); + const overlayRef = React.useRef(null); + const probeRef = React.useRef(null); + const closeRef = React.useRef(null); + // What had focus when the sheet opened. Radix hands focus back only to a + // `Dialog.Trigger`, and most bottom sheets open from a plain button (the + // More tab, a chart's tooltip), so focus fell to the body on close. + const openerRef = React.useRef(null); + const initial: SheetSnap = preview ? "preview" : "full"; + const [snap, setSnap] = React.useState(initial); + + const drag = useSheetDrag({ + enabled: draggable, + hasPreview: !!preview, + snap, + onSnap: setSnap, + onClose: () => closeRef.current?.click(), + contentRef, + overlayRef, + probeRef, + }); + + const setContent = React.useCallback( + (node: HTMLDivElement | null) => { + contentRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }, + [ref], + ); + + return ( + + +