diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 95944b652..2cb857975 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,18 @@ 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). 16.4.2 does the + # same (the day keys lost a step in 2 of 20 runs locally, the `?doc=` + # journey failed once in CI). Both were ours to fix: the sheet followed + # `useSearchParams`, which 16.4 updates after the write it copies, and a + # day step wrote the URL before Next had adopted the previous step, whose + # late adoption then put the older URL back. The sheet now follows the + # address bar and a step waits for the layer. Measured after both fixes: + # 200 of 200 repeated runs of the three journeys, and another 80 of 80 + # each for the `?doc=` and day-key journeys. - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5c1f748c8..3710d15fc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -23,9 +23,16 @@ jobs: # Two shards grew to 14–19 minutes against the 20-minute cap once the # mobile route sweep landed, and a slow runner tipped them over; three # shards with a 30-minute cap leave room for both. + # Every Playwright job runs inside Microsoft's Playwright image, which ships + # the browsers with their system libraries: installing them from apt at run + # time stalled shards for 29 minutes twice, once for WebKit and once for + # Chromium alone. shard: runs-on: ubuntu-24.04 + needs: playwright-version timeout-minutes: 30 + container: + image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble strategy: fail-fast: false matrix: @@ -38,8 +45,6 @@ jobs: POSTGRES_USER: healthlog POSTGRES_PASSWORD: healthlog POSTGRES_DB: healthlog - ports: - - 5432:5432 options: >- --health-cmd "pg_isready -U healthlog" --health-interval 5s @@ -47,7 +52,8 @@ jobs: --health-retries 10 env: - DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public + # Inside a job container the service is reached by its name. + DATABASE_URL: postgresql://healthlog:healthlog@postgres:5432/healthlog?schema=public ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' ENCRYPTION_ACTIVE_KEY_ID: v1 API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb @@ -56,10 +62,16 @@ jobs: AUTH_RP_ORIGIN: http://localhost:3000 SESSION_COOKIE_SECURE: "false" NODE_ENV: production + # The WebKit spec runs in the `webkit` job below. + E2E_SKIP_WEBKIT: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The image runs as root while the checkout belongs to the runner user; + # git refuses such a tree, and the prepare hook calls git. + - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 @@ -73,16 +85,6 @@ jobs: - run: pnpm db:migrate:deploy - - name: Cache Playwright browsers - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - playwright-${{ runner.os }}- - - - run: pnpm exec playwright install --with-deps chromium - # `pnpm build` (not bare `next build`): the prebuild hook generates # the versioned service-worker stamp AND the static i18n catalog # assets (public/i18n/.js) the client boots from — without @@ -125,19 +127,116 @@ jobs: retention-days: 7 if-no-files-found: error + # The Playwright image must carry the browsers of the exact Playwright the + # lockfile resolves, or the run refuses them. Read the version from the + # lockfile so a dependency bump moves the image with it. + playwright-version: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + version: ${{ steps.read.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: pnpm-lock.yaml + sparse-checkout-cone-mode: false + - id: read + run: | + version=$(grep -m1 -E "^ playwright-core@[0-9]" pnpm-lock.yaml | sed -E "s/^ playwright-core@([0-9.]+):.*/\1/") + if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + # The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the + # same image as the shards. + webkit: + runs-on: ubuntu-24.04 + needs: playwright-version + timeout-minutes: 30 + name: e2e webkit + container: + image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: healthlog + POSTGRES_PASSWORD: healthlog + POSTGRES_DB: healthlog + options: >- + --health-cmd "pg_isready -U healthlog" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + # Inside a job container the service is reached by its name. + DATABASE_URL: postgresql://healthlog:healthlog@postgres:5432/healthlog?schema=public + ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' + ENCRYPTION_ACTIVE_KEY_ID: v1 + API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + AUTH_RP_NAME: HealthLog + AUTH_RP_ID: localhost + AUTH_RP_ORIGIN: http://localhost:3000 + SESSION_COOKIE_SECURE: "false" + NODE_ENV: production + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The image runs as root while the checkout belongs to the runner user; + # git refuses such a tree, and the prepare hook calls git. + - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + - run: pnpm db:migrate:deploy + + - run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build + + - run: pnpm exec playwright test --project=webkit-iphone + env: + E2E_BASE_URL: http://localhost:3000 + + - name: Upload Playwright HTML report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: playwright-report-webkit + path: playwright-report/ + retention-days: 7 + if-no-files-found: ignore + + - name: Upload Playwright JSON report + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: playwright-json-shard-webkit + path: playwright-json/results.json + retention-days: 7 + if-no-files-found: error + # Branch protection requires a check named exactly "e2e". This job carries - # that name and answers for both shards: it fails when any shard failed or - # was cancelled, so a half-green matrix cannot satisfy the gate. + # that name and answers for every shard and the WebKit job: it fails when + # any of them failed or was cancelled, so a half-green run cannot satisfy + # the gate. e2e: runs-on: ubuntu-24.04 - needs: shard + needs: [shard, webkit] if: always() timeout-minutes: 5 steps: - name: Every shard finished green run: | - if [ "${{ needs.shard.result }}" != "success" ]; then - echo "shard result: ${{ needs.shard.result }}" + if [ "${{ needs.shard.result }}" != "success" ] || [ "${{ needs.webkit.result }}" != "success" ]; then + echo "shard result: ${{ needs.shard.result }}, webkit result: ${{ needs.webkit.result }}" exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0f73c02..834d69ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## [1.44.2] — 2026-10-22 + +### Changed + +- **Sheets that come up from the bottom can be pulled.** On a phone every + sheet now follows your finger: pull it down to close it, flick it away, + or let go early and it springs back. Content that is scrolled scrolls + first, sideways gestures such as sliders keep working, and the X, Escape + and a tap outside still close it. The day view opens at half height as + before and pulls up to almost the full screen. The page behind no longer + reloads when you pull inside a sheet, and closing a sheet puts the focus + back where you were. + +### Fixed + +- **Your own targets shape the dashboard charts.** Blood pressure, resting + pulse and body fat drew the standard band on the dashboard even when you + had set your own targets; they now draw yours, and the tile colours follow + them. The oxygen saturation page uses your target too. Without your own + targets nothing changes. (#1261) +- **Local thinking models finish the briefing, and regenerate waits for + them.** A thinking model such as Gemma 4 on Ollama could spend a long + briefing thinking and hand back no answer, which was logged as a + connection problem and shown as a timeout. A reply with nothing but + thinking is now asked once more without thinking, and that model is asked + that way for the next hour, through the Local provider and an + OpenAI-compatible gateway alike. The regenerate button waits as long as + your AI response timeout allows instead of giving up after 45 seconds, and + so does saving your notes about yourself; a local model allowed more than + five minutes is no longer cut off at five. A failed call is logged with + what actually went wrong, such as a refused or dropped connection, an + empty reply or a timeout, and the briefing only suggests a longer timeout + when time really ran out. The provider's reasoning setting now says that + the daily briefing thinks at its own depth. (#1267) + +### Thanks + +Thanks to @mills1975 for spotting that the targets never reached the chart +(#1261). + +Thanks to @Tio61 for the precise report that traced the regenerate timeout +to its line and ruled out the transport with their own requests from inside +the container (#1267). + +## [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 and the day ended one short. A step now + starts from the day in the address bar and waits until the previous one + is shown. +- **Back closes a document opened from the vault.** Pressed right after + opening a document, Back could leave its sheet open. The sheet now follows + the address bar directly. + +### 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 e06585d8f..3cc6674f6 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.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -64112,7 +64112,8 @@ components: - diaHigh additionalProperties: false - type: "null" - description: Personalised blood-pressure target numbers. Null when the profile has no date of birth. + description: "Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the + age-banded default. Null when there is neither a target nor a date of birth." bpSysRange: anyOf: - type: object @@ -64132,7 +64133,8 @@ components: - orangeMax additionalProperties: false - type: "null" - description: Systolic traffic-light range. Null when the profile has no date of birth. + description: Systolic traffic-light range, the user's own target winning over the age default. Null when there is + neither a target nor a date of birth. bpDiaRange: anyOf: - type: object @@ -64152,7 +64154,8 @@ components: - orangeMax additionalProperties: false - type: "null" - description: Diastolic traffic-light range. Null when the profile has no date of birth. + description: Diastolic traffic-light range, the user's own target winning over the age default. Null when there is + neither a target nor a date of birth. pulseDisplayRange: type: object properties: @@ -64170,7 +64173,8 @@ components: - orangeMin - orangeMax additionalProperties: false - description: Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth. + description: "Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA + reference range with no date of birth). Always present." pulseBands: type: array items: @@ -64191,7 +64195,7 @@ components: - max - color additionalProperties: false - description: Resting-pulse chart bands. Always present — AHA fallback with no date of birth. + description: Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present. bodyFatRange: type: object properties: @@ -64203,7 +64207,7 @@ components: - min - max additionalProperties: false - description: Gender-aware body-fat target range. Always present. + description: "Body-fat target range: the user's own target where set, else the gender-aware default. Always present." bodyFatBands: type: array items: @@ -64224,7 +64228,7 @@ components: - max - color additionalProperties: false - description: Body-fat chart bands. Always present. + description: Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present. weightRange: anyOf: - type: object 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 +692,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/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 187ce48a0..ffbb2e535 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -31,7 +31,14 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; @@ -110,6 +117,26 @@ const BULK_VISIT_OPTIONS = 12; const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/; const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection"; +/** + * The query string in the address bar, read at render time. + * + * The open sheet follows `?doc=`, and `useSearchParams` is not a faithful + * copy of it: Next 16.4 applies the copy of a `pushState` later than the write, + * and a Back pressed in between moves the address bar from `?doc=` to the + * page without it while `useSearchParams` can read "no document" on both sides. + * The sheet then saw no change and stayed open. `window.location` is never + * behind; `popstate` re-reads it on every traversal, and every other render + * (including the ones Next's own navigations cause) reads it afresh. + */ +function subscribeToTraversal(onChange: () => void): () => void { + window.addEventListener("popstate", onChange); + return () => window.removeEventListener("popstate", onChange); +} + +function readLocationSearch(): string { + return window.location.search; +} + export function documentSelectionHistoryState( documentId: string, ): Record { @@ -818,7 +845,13 @@ export function DocumentsView() { const [detailOpen, setDetailOpen] = useState(false); const handedOffDocumentRef = useRef(null); - const rawDocParam = searchParams.get("doc"); + // Before hydration the server's view of the query stands in for the URL. + const locationSearch = useSyncExternalStore( + subscribeToTraversal, + readLocationSearch, + () => searchParams.toString(), + ); + const rawDocParam = new URLSearchParams(locationSearch).get("doc"); const docParam = rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam) ? rawDocParam @@ -837,14 +870,14 @@ export function DocumentsView() { const openDetail = useCallback( (id: string) => { - const href = documentSelectionHref(pathname, searchParams.toString(), id); + const href = documentSelectionHref(pathname, window.location.search, id); if (href === null) return; handedOffDocumentRef.current = null; setDetailId(id); setDetailOpen(true); window.history.pushState(documentSelectionHistoryState(id), "", href); }, - [pathname, searchParams], + [pathname], ); const handleDetailOpenChange = useCallback((open: boolean) => { @@ -881,7 +914,7 @@ export function DocumentsView() { handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff( window.history, pathname, - searchParams.toString(), + window.location.search, docParam, handedOffDocumentRef.current, coachLaunch?.closeIntent, @@ -925,7 +958,6 @@ export function DocumentsView() { docParam, moduleEnabled, pathname, - searchParams, ]); // Hover/focus intent prefetches the detail METADATA (never the blob — diff --git a/src/components/insights/__tests__/use-insights-advisor-force.test.ts b/src/components/insights/__tests__/use-insights-advisor-force.test.ts index 5348f35b5..880a3022d 100644 --- a/src/components/insights/__tests__/use-insights-advisor-force.test.ts +++ b/src/components/insights/__tests__/use-insights-advisor-force.test.ts @@ -7,7 +7,7 @@ * button reads as doing nothing (one half of the recurring * "no briefing today" chain). The read path stays a plain GET. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const apiFetchRaw = vi.fn(); @@ -15,7 +15,7 @@ vi.mock("@/lib/api/api-fetch", () => ({ apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a), })); -import { fetchAdvisor } from "../use-insights-advisor"; +import { fetchAdvisor, forceAdvisorTimeoutMs } from "../use-insights-advisor"; function jsonResponse(data: unknown): Response { return { @@ -74,3 +74,56 @@ describe("fetchAdvisor — explicit regenerate forces a generation", () => { expect(result.payload?.dailyBriefing).toBeNull(); }); }); + +describe("fetchAdvisor — the regenerate waits as long as the response timeout allows (#1267)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + /** A POST that only ends when its signal aborts, like a slow model. */ + function hangUntilAborted() { + apiFetchRaw.mockImplementation( + (_url: string, init: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }), + ); + } + + it("is still waiting at 45 s and at 10 min with a 600 s setting", async () => { + vi.useFakeTimers(); + hangUntilAborted(); + let settled: unknown = null; + void fetchAdvisor({ force: true, responseTimeoutMs: 600_000 }).then( + (r) => (settled = r), + ); + await vi.advanceTimersByTimeAsync(45_001); + expect(settled).toBeNull(); + await vi.advanceTimersByTimeAsync(10 * 60_000); + expect(settled).toBeNull(); + await vi.advanceTimersByTimeAsync(forceAdvisorTimeoutMs(600_000)); + expect(settled).toEqual({ payload: null, outcome: "timeout" }); + }); + + it("never waits less than the briefing's own 180 s per call", () => { + // 60 s is what the payload publishes when nothing is set; the route still + // gives each briefing call 180 s. + expect(forceAdvisorTimeoutMs(60_000)).toBeGreaterThanOrEqual(2 * 180_000); + expect(forceAdvisorTimeoutMs(undefined)).toBe( + forceAdvisorTimeoutMs(60_000), + ); + }); + + it("keeps the read path at its fast 8 s bound", async () => { + vi.useFakeTimers(); + hangUntilAborted(); + let settled: unknown = null; + void fetchAdvisor({ responseTimeoutMs: 600_000 }).then( + (r) => (settled = r), + ); + await vi.advanceTimersByTimeAsync(8_001); + expect(settled).toEqual({ payload: null, outcome: "timeout" }); + }); +}); diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 9c873a1c5..645ec9153 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -713,7 +713,7 @@ function InsightsTabStripImpl({ // // Refs #786 — the edge watches the COMBINED busy state (`regenerating || // regenerateSettling`): a timed-out regenerate flows straight into - // settling, so the spinner never falls at 45 s and NO error toast fires + // settling, so the spinner never falls at the client abort and NO error toast fires // while the server may still deliver. The edge fires once settling // resolves, with the proven outcome — `"fresh"` (success) or // `"settle-failed"` (its own honest error copy). The mapping is the pure diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 9633a652e..10fa7bc60 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -17,7 +17,9 @@ import type { } from "@/lib/ai/schema"; import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { apiFetchRaw } from "@/lib/api/api-fetch"; -import { useAiCapability } from "@/hooks/use-ai-capability"; +import { useAiCapability, useAiProviderState } from "@/hooks/use-ai-capability"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { clientAbortMs } from "@/lib/ai/effective-timeout"; import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker"; /** @@ -116,16 +118,29 @@ const ADVISOR_TIMEOUT_MS = 8_000; /** * v1.15.18 — the user-initiated regenerate (`force`) POSTs an INLINE - * generation: a ~1500-token warm completion that routinely runs longer - * than the 8 s read budget. Bounding the force branch at 8 s silently - * discarded a slow-but-successful generation — the abort returned a null - * payload, the mutation only invalidated (re-reading the OLD cache), yet - * the server had often already written the fresh briefing. Give the force - * branch a generous 45 s client budget so a slow success is kept, not - * dropped. The READ path stays fast at 8 s — only the explicit user tap - * may wait. Per `.planning/v1.15.18-daily-briefing-audit.md` Fix 1b. + * generation that routinely runs longer than the 8 s read budget; bounding it + * at 8 s discarded a slow-but-successful generation. + * + * #1267 — the budget follows the person's response-timeout setting instead of + * a constant 45 s, which cut every regenerate on a slow local model while the + * server still had minutes left. The route makes up to two sequential model + * calls (the generation and its grounding retry), and a reasoning model that + * answered nothing is asked once more inside the first, so three calls at the + * per-call timeout, at least the briefing's own 180 s default each. Never + * shorter than the server: the abort is only for a request nobody answers. */ -const FORCE_ADVISOR_TIMEOUT_MS = 45_000; +const FORCE_ADVISOR_MODEL_CALLS = 3; + +/** The client budget for a forced regenerate (exported for the test). */ +export function forceAdvisorTimeoutMs( + responseTimeoutMs: number | null | undefined, +): number { + return clientAbortMs( + responseTimeoutMs, + FORCE_ADVISOR_MODEL_CALLS, + AI_BUDGETS.comprehensive.timeoutMs, + ); +} /** * v1.16.7 — poll cadence + ceiling while the server reports @@ -146,7 +161,7 @@ export const ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS = 10; * * Async-truth contract, client end (the server end is documented at the head * of `src/app/api/insights/generate/route.ts`): the force POST generates - * INLINE and keeps running after the client aborts at 45 s — the server + * INLINE and keeps running after the client aborts — the server * usually writes the fresh cache moments later. A page that knows work is * running must keep saying so until it can prove an outcome, so the timeout * is NOT surfaced as an error. Instead the hook remembers the baseline it was @@ -276,12 +291,18 @@ interface AdvisorFetchResult { * no-briefing-today chain). The wire-contract test pins it. */ export async function fetchAdvisor( - options: { force?: boolean } = {}, + options: { + force?: boolean; + /** `ai.provider.responseTimeoutMs` from the account payload. */ + responseTimeoutMs?: number | null; + } = {}, ): Promise { const controller = new AbortController(); const timeoutHandle = setTimeout( () => controller.abort(), - options.force ? FORCE_ADVISOR_TIMEOUT_MS : ADVISOR_TIMEOUT_MS, + options.force + ? forceAdvisorTimeoutMs(options.responseTimeoutMs) + : ADVISOR_TIMEOUT_MS, ); let res: Response; try { @@ -437,6 +458,7 @@ export function useInsightsAdvisorQuery( enabled: boolean, ): UseInsightsAdvisorResult { const briefing = useAiCapability("briefing"); + const { responseTimeoutMs } = useAiProviderState(); const queryClient = useQueryClient(); // Refs #786 — settling state for a timed-out force regenerate (see the // contract comment above `AdvisorSettleState`). `settleOutcome` carries the @@ -500,7 +522,7 @@ export function useInsightsAdvisorQuery( } const mutation = useMutation({ - mutationFn: () => fetchAdvisor({ force: true }), + mutationFn: () => fetchAdvisor({ force: true, responseTimeoutMs }), onSuccess: (result) => { if (result.payload) { // A genuinely fresh generation landed — write it into the shared @@ -509,7 +531,7 @@ export function useInsightsAdvisorQuery( } else { // No fresh payload (timeout / no-provider / transient). The server's // inline POST may STILL have written a fresh briefing after the - // client gave up at 45 s, so re-read the GET to converge — but the + // client gave up, so re-read the GET to converge — but the // honest toast is gated on `regenerateOutcome` below, not on this // invalidate. // 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/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": diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index ba06c061b..b73fdf595 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/profile/height-unit-display"; import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability"; import { localizedApiError } from "@/lib/api/localized-error"; import { AnamnesisCard, @@ -153,6 +154,7 @@ export function BaselineForm({ allergies: "", }); const [baseAboutMe, setBaseAboutMe] = useState(""); + const saveTimeoutMs = useAboutMeSaveTimeoutMs(); useEffect(() => { let active = true; @@ -229,7 +231,9 @@ export function BaselineForm({ // collapsed card never round-trips). const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis); if (aboutMeBody) { - await apiPut("/api/coach/about-me", aboutMeBody); + await apiPut("/api/coach/about-me", aboutMeBody, { + signal: AbortSignal.timeout(saveTimeoutMs), + }); } } await onConfirmed(); diff --git a/src/components/profile/__tests__/height-entry-surfaces.test.tsx b/src/components/profile/__tests__/height-entry-surfaces.test.tsx index 86661e422..03c9310c2 100644 --- a/src/components/profile/__tests__/height-entry-surfaces.test.tsx +++ b/src/components/profile/__tests__/height-entry-surfaces.test.tsx @@ -28,6 +28,13 @@ vi.mock("@tanstack/react-query", () => ({ useMutation: () => ({ mutate: vi.fn(), isPending: false }), })); +// #1267 — the about-me save's abort reads the account payload; the +// react-query mock above has no client to read it from. +vi.mock("@/hooks/use-ai-capability", async (importOriginal) => ({ + ...(await importOriginal()), + useAboutMeSaveTimeoutMs: () => 90_000, +})); + vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() }, })); diff --git a/src/components/records/__tests__/ai-profile-inclusion-manager.test.tsx b/src/components/records/__tests__/ai-profile-inclusion-manager.test.tsx index 8cdb6c73e..3bbfc0744 100644 --- a/src/components/records/__tests__/ai-profile-inclusion-manager.test.tsx +++ b/src/components/records/__tests__/ai-profile-inclusion-manager.test.tsx @@ -32,6 +32,14 @@ vi.mock("@tanstack/react-query", () => ({ }; }, })); + +// #1267 — the about-me save's abort reads the account payload; the +// react-query mock above has no client to read it from. +vi.mock("@/hooks/use-ai-capability", async (importOriginal) => ({ + ...(await importOriginal()), + useAboutMeSaveTimeoutMs: () => 90_000, +})); + vi.mock("@/lib/api/api-fetch", () => ({ apiGet: vi.fn(), apiPut: vi.fn(), diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index 422fd6001..72ffd8fd0 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -38,7 +38,10 @@ import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsInfoTile } from "@/components/settings/_info-tile"; import { Label } from "@/components/ui/label"; import { ConfirmButton } from "@/components/ui/confirm-button"; -import { useAiCapability } from "@/hooks/use-ai-capability"; +import { + useAboutMeSaveTimeoutMs, + useAiCapability, +} from "@/hooks/use-ai-capability"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; @@ -82,6 +85,7 @@ export function AboutMeNoteManager() { // there is a note to clear. const hasNote = savedNote.length > 0; + const saveTimeoutMs = useAboutMeSaveTimeoutMs(); const save = useMutation({ mutationKey: queryKeys.coachAboutMe(), // Send only the note. `conditions` / `coachFocus` / `allergies` are @@ -93,6 +97,7 @@ export function AboutMeNoteManager() { return apiPut( "/api/coach/about-me", withBaseToken(input, query.data?.updatedAt ?? undefined), + { signal: AbortSignal.timeout(saveTimeoutMs) }, ); }, onSuccess: (next) => { diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index 961faf4aa..e29f3bb62 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -11,6 +11,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Switch } from "@/components/ui/switch"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability"; import { isConflict, readUpdatedAtToken, @@ -72,6 +73,7 @@ export function AiProfileInclusionManager() { (section) => draft.includes(section) !== saved.includes(section), ); + const saveTimeoutMs = useAboutMeSaveTimeoutMs(); const save = useMutation({ mutationKey: queryKeys.coachAboutMe(), mutationFn: (sections: HealthProfileAiSection[]) => @@ -84,6 +86,7 @@ export function AiProfileInclusionManager() { readUpdatedAtToken(queryClient, queryKeys.coachAboutMe()) ?? undefined, ), + { signal: AbortSignal.timeout(saveTimeoutMs) }, ), onSuccess: () => { setDraft(null); diff --git a/src/components/records/allergy-free-text-note.tsx b/src/components/records/allergy-free-text-note.tsx index c974bdbef..aac23154c 100644 --- a/src/components/records/allergy-free-text-note.tsx +++ b/src/components/records/allergy-free-text-note.tsx @@ -36,6 +36,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability"; import { withBaseToken, isConflict } from "@/lib/api/optimistic-token"; const FALLBACK_FIELD_MAX_CHARS = 500; @@ -75,6 +76,7 @@ export function AllergyFreeTextNote({ const value = draft ?? savedLine; const dirty = value !== savedLine; + const saveTimeoutMs = useAboutMeSaveTimeoutMs(); const save = useMutation({ mutationKey: queryKeys.coachAboutMe(), // Send only the allergies line — omitted fields are preserved server-side @@ -84,6 +86,7 @@ export function AllergyFreeTextNote({ return apiPut( "/api/coach/about-me", withBaseToken(input, query.data?.updatedAt ?? undefined), + { signal: AbortSignal.timeout(saveTimeoutMs) }, ); }, onSuccess: () => { diff --git a/src/components/records/conditions-manager.tsx b/src/components/records/conditions-manager.tsx index 42b2f2cdb..c876c1105 100644 --- a/src/components/records/conditions-manager.tsx +++ b/src/components/records/conditions-manager.tsx @@ -35,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability"; const FALLBACK_FIELD_MAX_CHARS = 500; @@ -76,13 +77,18 @@ export function ConditionsManager() { value.conditions !== saved.conditions || value.coachFocus !== saved.coachFocus; + const saveTimeoutMs = useAboutMeSaveTimeoutMs(); const save = useMutation({ mutationKey: queryKeys.coachAboutMe(), mutationFn: async (input: { conditions: string; coachFocus: string }) => - apiPut("/api/coach/about-me", { - conditions: input.conditions, - coachFocus: input.coachFocus, - }), + apiPut( + "/api/coach/about-me", + { + conditions: input.conditions, + coachFocus: input.coachFocus, + }, + { signal: AbortSignal.timeout(saveTimeoutMs) }, + ), onSuccess: () => { toast.success(t("records.conditions.savedToast")); setDrafts({ conditions: null, coachFocus: null }); diff --git a/src/components/records/free-text-take-over.tsx b/src/components/records/free-text-take-over.tsx index 400024b04..e52f2f608 100644 --- a/src/components/records/free-text-take-over.tsx +++ b/src/components/records/free-text-take-over.tsx @@ -26,6 +26,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability"; import { withBaseToken } from "@/lib/api/optimistic-token"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; @@ -90,12 +91,14 @@ export function FreeTextTakeOver({ showErrorToast(t("records.life.takeOver.saveError"), { cause: error }), }); + const saveTimeoutMs = useAboutMeSaveTimeoutMs(); const clear = useMutation({ mutationKey: queryKeys.coachAboutMe(), mutationFn: () => apiPut( "/api/coach/about-me", withBaseToken({ [field]: "" }, aboutMe.data?.updatedAt ?? undefined), + { signal: AbortSignal.timeout(saveTimeoutMs) }, ), onSuccess: () => { toastWrittenOutcome("success", t("records.life.takeOver.clearedToast")); diff --git a/src/components/settings/__tests__/coach-links-without-coach.test.tsx b/src/components/settings/__tests__/coach-links-without-coach.test.tsx index 5025a769a..fb011f619 100644 --- a/src/components/settings/__tests__/coach-links-without-coach.test.tsx +++ b/src/components/settings/__tests__/coach-links-without-coach.test.tsx @@ -17,6 +17,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({ }), // v1.41 — the thinking depth reads the resolved block; none here. useCoachReasoning: () => null, + // #1267 — the about-me save's abort, from the response-timeout setting. + useAboutMeSaveTimeoutMs: () => 90_000, })); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), diff --git a/src/components/settings/__tests__/settings-read-error-family.test.tsx b/src/components/settings/__tests__/settings-read-error-family.test.tsx index 18f03becd..0acb1a061 100644 --- a/src/components/settings/__tests__/settings-read-error-family.test.tsx +++ b/src/components/settings/__tests__/settings-read-error-family.test.tsx @@ -33,6 +33,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({ reason: null, onDeviceAllowed: true, }), + // #1267 — the about-me save's abort, from the response-timeout setting. + useAboutMeSaveTimeoutMs: () => 90_000, })); vi.mock("next/navigation", () => ({ diff --git a/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx b/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx index 0f2f2efd2..d15d5ff83 100644 --- a/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx +++ b/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx @@ -99,6 +99,12 @@ describe.each([ expect(select).toMatch(/
); } 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 [node, setNode] = React.useState(null); + + const drag = useSheetDrag({ + enabled: draggable, + hasPreview: !!preview, + snap, + onSnap: setSnap, + onClose: () => closeRef.current?.click(), + contentRef, + overlayRef, + probeRef, + node, + }); + + const setContent = React.useCallback( + (node: HTMLDivElement | null) => { + contentRef.current = node; + setNode(node); + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }, + [ref], + ); + + return ( + + +