From 555a529260f19e8444beaf0952d02ddf857a6a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 15:09:13 +0100 Subject: [PATCH 01/19] fix(sheets): keep Back on the page a closed sheet returns to Three ways the sheet's history entry could still cost a Back: - A close that also writes the URL (the day's date picker, the life event sheet opened by ?add=lifeEvent) let the router's answer to that write land on the page entry with the closed sheet's mark. The next sheet close then stepped one entry past the page, and on an installed app opened from a notification that closed the app. A write that carries the mark of a sheet that is gone now loses it. - A sheet's entry started empty, and landing below it copied that empty state onto the page entry, so an open day or document lost the key it keys its own Back on. The sheet's entry now starts as a copy of the page entry's state. - The account form's Back guard heard the sheet's own Back first when it was mounted before the first sheet of the visit, and asked to discard the form's edits for closing the section list. The guard now leaves a popstate the sheet history owns to it. The browser tests run against Chromium's session history and fail without each fix. --- .../seeded-form-guard-sheet.browser.test.tsx | 78 ++++++++++++++ .../forms/use-seeded-form-dismissal.tsx | 4 + .../__tests__/sheet-history.browser.test.tsx | 100 ++++++++++++++++++ src/components/ui/sheet-history.ts | 40 +++++-- 4 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 src/components/forms/__tests__/seeded-form-guard-sheet.browser.test.tsx create mode 100644 src/components/ui/__tests__/sheet-history.browser.test.tsx diff --git a/src/components/forms/__tests__/seeded-form-guard-sheet.browser.test.tsx b/src/components/forms/__tests__/seeded-form-guard-sheet.browser.test.tsx new file mode 100644 index 000000000..13a59e524 --- /dev/null +++ b/src/components/forms/__tests__/seeded-form-guard-sheet.browser.test.tsx @@ -0,0 +1,78 @@ +/** + * A form that guards Back (`useSeededFormDismissal({ guardNavigation })`) + * and a sheet opened over it both listen for popstate in the capture phase, + * so the one registered first hears Back first. Here the guard is: the page + * mounts the form, and the sheet history installs at the first sheet open, + * which is the order on `/settings/account` when "All sections" is the first + * sheet of the visit. A Back that closes the sheet is the sheet's, and the + * form must not answer it with its discard question. + */ +import { describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { sheetHistory } from "@/components/ui/sheet-history"; + +import { useSeededFormDismissal } from "../use-seeded-form-dismissal"; + +type Navigation = { currentEntry: { index: number } }; +const index = () => + (window as unknown as { navigation: Navigation }).navigation.currentEntry + .index; + +let popstates = 0; +window.addEventListener("popstate", () => (popstates += 1), true); + +async function settle(count: number): Promise { + const target = popstates + count; + await vi.waitFor(() => expect(popstates).toBeGreaterThanOrEqual(target), { + timeout: 2_000, + }); + for (;;) { + const seen = popstates; + await new Promise((resolve) => setTimeout(resolve, 150)); + if (popstates === seen) return; + } +} + +function EditedForm() { + const { discardDialogOpen } = useSeededFormDismissal({ + seed: "Ada", + value: "Ada L.", + guardNavigation: true, + navigate: () => {}, + }); + return

{discardDialogOpen ? "asking" : "quiet"}

; +} + +describe("a guarded form under a sheet", () => { + // One case: the listener order is set once per page, by the first open. + it("leaves the sheet's Back to the sheet and guards the page's", async () => { + window.history.pushState(null, "", "?section=account"); + const page = index(); + const screen = await render(); + const asking = screen.getByTestId("asking"); + + // Back closes the sheet. + const onBack = vi.fn(); + sheetHistory()!.open(onBack); + expect(index()).toBe(page + 1); + window.history.back(); + await settle(1); + expect(onBack).toHaveBeenCalledTimes(1); + expect(index()).toBe(page); + await expect.element(asking).toHaveTextContent("quiet"); + + // The sheet's own close takes its entry off. + sheetHistory()! + .open(() => {}) + .release(); + await settle(1); + expect(index()).toBe(page); + await expect.element(asking).toHaveTextContent("quiet"); + + // With no sheet open, Back is the form's. + window.history.back(); + await settle(1); + await expect.element(asking).toHaveTextContent("asking"); + }); +}); diff --git a/src/components/forms/use-seeded-form-dismissal.tsx b/src/components/forms/use-seeded-form-dismissal.tsx index 58a3bec84..361476a5b 100644 --- a/src/components/forms/use-seeded-form-dismissal.tsx +++ b/src/components/forms/use-seeded-form-dismissal.tsx @@ -12,6 +12,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { sheetHistoryOwnsPopstate } from "@/components/ui/sheet-history"; import { useTranslations } from "@/lib/i18n/context"; type CloseRequest = () => void; @@ -347,6 +348,9 @@ export function useSeededFormDismissal({ }); historyTraversalGuardRef.current = historyTraversalGuard; const handlePopState = (event: PopStateEvent) => { + // A Back that closes a sheet over the form is the sheet's to answer; + // whichever capture listener was added first hears it first. + if (sheetHistoryOwnsPopstate()) return; historyTraversalGuard.handlePopState(event); }; diff --git a/src/components/ui/__tests__/sheet-history.browser.test.tsx b/src/components/ui/__tests__/sheet-history.browser.test.tsx new file mode 100644 index 000000000..78ec10989 --- /dev/null +++ b/src/components/ui/__tests__/sheet-history.browser.test.tsx @@ -0,0 +1,100 @@ +/** + * Sheet history against Chromium's own session history: `back()` answers + * with a real popstate a task or more later, and the entries keep their + * state the way the browser keeps it. + * + * The router's half is played by hand where it matters: Next answers a URL + * write one scheduler task later by rewriting the current entry with that + * entry's own state copied in (`HistoryUpdater`), and that write can land + * after a sheet's close has asked for Back but before Back arrives. + */ +import { describe, expect, it, vi } from "vitest"; + +import { DAY_HISTORY_KEY } from "@/components/day/day-url"; + +import { sheetHistory } from "../sheet-history"; + +type Navigation = { currentEntry: { index: number } }; +const index = () => + (window as unknown as { navigation: Navigation }).navigation.currentEntry + .index; +const state = () => (window.history.state ?? {}) as Record; +const MARK = "__healthlogSheet"; + +// Registered before the sheet history installs its own listener at the +// first open, so it hears every popstate, including the ones it swallows. +let popstates = 0; +window.addEventListener("popstate", () => (popstates += 1), true); + +/** Wait for `count` more popstates, then until none follows. */ +async function settle(count: number): Promise { + const target = popstates + count; + await vi.waitFor(() => expect(popstates).toBeGreaterThanOrEqual(target), { + timeout: 2_000, + }); + for (;;) { + const seen = popstates; + await new Promise((resolve) => setTimeout(resolve, 150)); + if (popstates === seen) return; + } +} + +describe("sheet history in the browser", () => { + it("leaves the page entry unmarked when the close also writes the URL", async () => { + const sheets = sheetHistory()!; + window.history.pushState(null, "", "?step=before"); + window.history.pushState(null, "", "?step=page"); + const page = index(); + + // The day's date picker: its handler writes the day into the URL while + // the sheet's entry is current, then the sheet closes. + const picker = sheets.open(() => {}); + expect(index()).toBe(page + 1); + window.history.replaceState(null, "", "?step=page&day=2026-11-10"); + picker.release(); + // The router's answer to that write, copying the entry it finds. + window.history.replaceState( + { ...state(), __NA: true }, + "", + window.location.href, + ); + await settle(1); + + expect(index()).toBe(page); + expect(window.location.search).toBe("?step=page&day=2026-11-10"); + expect(state()[MARK]).toBeUndefined(); + + // The next sheet closes onto the page, not onto the entry before it. + const again = sheets.open(() => {}); + again.release(); + await settle(1); + expect(index()).toBe(page); + expect(window.location.search).toBe("?step=page&day=2026-11-10"); + }); + + it("keeps the page entry's own state through a sheet", async () => { + const sheets = sheetHistory()!; + window.history.pushState( + { [DAY_HISTORY_KEY]: "2026-11-10" }, + "", + "?day=2026-11-10", + ); + const page = index(); + + // Closed by its own button. + sheets.open(() => {}).release(); + await settle(1); + expect(index()).toBe(page); + expect(state()[DAY_HISTORY_KEY]).toBe("2026-11-10"); + + // Closed by Back. + const onBack = vi.fn(); + sheets.open(onBack); + window.history.back(); + await settle(1); + expect(onBack).toHaveBeenCalledTimes(1); + expect(index()).toBe(page); + expect(state()[DAY_HISTORY_KEY]).toBe("2026-11-10"); + expect(state()[MARK]).toBeUndefined(); + }); +}); diff --git a/src/components/ui/sheet-history.ts b/src/components/ui/sheet-history.ts index e1c232679..b7059fd14 100644 --- a/src/components/ui/sheet-history.ts +++ b/src/components/ui/sheet-history.ts @@ -26,7 +26,13 @@ import { URL_STATE_EVENT } from "@/lib/url-state"; * sheet) replaces that entry, so Back from the new page returns to the * page, not to a sheet entry nobody shows any more. * - A replace of a sheet's entry keeps the sheet's mark: the router - * rewrites the current entry on every update. + * rewrites the current entry on every update. A write that carries the + * mark of a sheet that has closed (the router copying an entry it read + * before our Back landed) loses it, or the entry would read as a sheet's + * and the next close would step one Back too far. + * + * A sheet's entry starts as a copy of the page entry's state, so landing + * on the page entry gives it back its own keys (an open day's, the vault's). * * A sheet whose open state already lives in the URL (the vault's `?doc=`) * has its own entry and opts out (`ResponsiveSheet historyEntry={false}`). @@ -74,6 +80,12 @@ export interface SheetHistory { * entry is gone by then, and `restore` puts it back. */ open(onBack: () => void): SheetHistoryEntry; + /** + * Whether the popstate being dispatched is ours: a Back we asked for, or + * one that lands on a sheet's entry or right below the first sheet. Asked + * by other capture listeners, which may hear it before we do. + */ + ownsPopstate(): boolean; } interface Layer { @@ -108,6 +120,14 @@ function samePage(a: string, b: string): boolean { return left.pathname === right.pathname && left.search === right.search; } +/** `data` without the mark of a sheet that is no longer open. */ +function withoutDeadMark(data: unknown, open: readonly Layer[]): unknown { + const id = markOf(data); + return id === undefined || open.some((layer) => layer.id === id) + ? data + : withMark(data, undefined); +} + let sequence = 0; export function createSheetHistory(host: SheetHistoryHost): SheetHistory { @@ -149,16 +169,14 @@ export function createSheetHistory(host: SheetHistoryHost): SheetHistory { function install() { if (installed) return; installed = true; - const below = { - push: history.pushState, - replace: history.replaceState, - }; + const below = { push: history.pushState, replace: history.replaceState }; push = (data, unused, url) => { if (ours) return below.push.call(history, data, unused, url); if (ownBacks > 0) { held.push(() => push.call(history, data, unused, url)); return; } + data = withoutDeadMark(data, layers); const current = layerOf(markOf(history.state)); if (current && url != null && !samePage(location.href, String(url))) { below.replace.call(history, data, unused, url); @@ -172,6 +190,7 @@ export function createSheetHistory(host: SheetHistoryHost): SheetHistory { held.push(() => replace.call(history, data, unused, url)); return; } + data = withoutDeadMark(data, layers); const id = markOf(history.state); const keep = layerOf(id) && markOf(data) === undefined; below.replace.call( @@ -264,11 +283,15 @@ export function createSheetHistory(host: SheetHistoryHost): SheetHistory { } if (!layers.includes(layer)) return; layer.baseHref = location.href; - write("push", withMark(null, layer.id)); + write("push", withMark(history.state, layer.id)); snapshot(); } return { + ownsPopstate: () => + ownBacks > 0 || + layerOf(markOf(history.state)) !== undefined || + (layers.length > 0 && location.href === layers[0].baseHref), open(onBack) { install(); sequence += 1; @@ -314,3 +337,8 @@ export function sheetHistory(): SheetHistory | null { shared ??= createSheetHistory(window as unknown as SheetHistoryHost); return shared; } + +/** Whether the popstate under way belongs to an open or closing sheet. */ +export function sheetHistoryOwnsPopstate(): boolean { + return shared?.ownsPopstate() ?? false; +} From 218986ea89de61a2f3985916391901950e44056c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 15:09:22 +0100 Subject: [PATCH 02/19] fix(sheets): let Back answer the discard question with "keep editing" Back on a sheet with unsaved input asks whether to discard it. A second Back while that question was open put the sheet's entry back and asked again, so the Android button could never get past the question. It now closes the question and keeps the sheet and its entry, like Cancel. --- .../__tests__/sheet-history.browser.test.tsx | 39 +++++++++++++++++++ src/components/ui/responsive-sheet.tsx | 5 ++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/components/ui/__tests__/sheet-history.browser.test.tsx b/src/components/ui/__tests__/sheet-history.browser.test.tsx index 78ec10989..bd57bbdea 100644 --- a/src/components/ui/__tests__/sheet-history.browser.test.tsx +++ b/src/components/ui/__tests__/sheet-history.browser.test.tsx @@ -8,10 +8,13 @@ * entry's own state copied in (`HistoryUpdater`), and that write can land * after a sheet's close has asked for Back but before Back arrives. */ +import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; import { DAY_HISTORY_KEY } from "@/components/day/day-url"; +import { renderWithApp } from "../../../../tests/browser/render"; +import { ResponsiveSheet } from "../responsive-sheet"; import { sheetHistory } from "../sheet-history"; type Navigation = { currentEntry: { index: number } }; @@ -97,4 +100,40 @@ describe("sheet history in the browser", () => { expect(state()[DAY_HISTORY_KEY]).toBe("2026-11-10"); expect(state()[MARK]).toBeUndefined(); }); + + it("answers Back during the discard question by keeping the sheet open", async () => { + window.history.pushState(null, "", "?step=form"); + const page = index(); + function EditedSheet() { + const [open, setOpen] = useState(true); + return ( + +

Typed but not saved

+
+ ); + } + const { screen } = await renderWithApp(); + await expect.element(screen.getByText("Typed but not saved")).toBeVisible(); + await vi.waitFor(() => expect(index()).toBe(page + 1)); + + // Back with unsaved input asks first and puts the sheet's entry back. + window.history.back(); + await settle(1); + await expect.element(screen.getByRole("alertdialog")).toBeVisible(); + expect(index()).toBe(page + 1); + + // Back again means "not now": the question goes, the sheet stays. + window.history.back(); + await settle(1); + await expect + .element(screen.getByRole("alertdialog")) + .not.toBeInTheDocument(); + await expect.element(screen.getByText("Typed but not saved")).toBeVisible(); + expect(index()).toBe(page + 1); + }); }); diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index 779bddc6d..ac634acf6 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -235,9 +235,10 @@ function useSheetDismissal({ }; const handleBack = React.useEffectEvent(() => { - if (hasUnsavedInput()) { + // Back while the question is open answers it with "keep editing". + if (asking || hasUnsavedInput()) { entryRef.current?.restore(); - setAsking(true); + setAsking(!asking); return; } countBack(); From cb569b6c9d61c80d819257d42223b148a0754c87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 15:09:29 +0100 Subject: [PATCH 03/19] fix(sheets): stop two capture forms asking before anything was entered The symptom form and the dashboard's medication intake asked "Discard?" on every close, untouched. The sheet's fallback check read the input Radix keeps for the intensity slider (value "0" before anyone moves it) and the dose the form fills in from the preselected medication as typed input. The check now skips a slider's own input and counts a slider moved off unanswered instead, and compares a field against the value its form says it filled in (`data-seed`). A controlled input's defaultValue follows its value, so it cannot tell the two apart. The dose field names the medication's dose, one line more for the form's baseline. --- .../baselines/source-function-lines.json | 2 +- .../dashboard/medication-intake-quick-add.tsx | 1 + .../sheet-dirty-check.browser.test.tsx | 81 +++++++++++++++++++ .../ui/__tests__/sheet-dirty-check.test.ts | 1 + src/components/ui/responsive-sheet.tsx | 22 +++-- 5 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 src/components/ui/__tests__/sheet-dirty-check.browser.test.tsx diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 595676ba3..1add8f02f 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -84,7 +84,7 @@ "components/cycle/log-day-sheet.tsx :: LogDaySheet": 760, "components/daily/today-hero.tsx :: TodayHero": 488, "components/dashboard/dashboard-header.tsx :: DashboardHeader": 215, - "components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 383, + "components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 384, "components/day/day-layer.tsx :: DayLayer": 358, "components/day/day-view.tsx :: DayView": 309, "components/documents/document-ai-section.tsx :: DocumentAiSection": 262, diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 0c662b618..1b57d4fdf 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -451,6 +451,7 @@ export function MedicationIntakeQuickAdd({ id="medication-intake-dose" data-testid="medication-intake-quick-add-dose" value={dose} + data-seed={selectedMedication?.dose ?? ""} onChange={(e) => setDoseOverride(e.target.value)} maxLength={50} placeholder={ diff --git a/src/components/ui/__tests__/sheet-dirty-check.browser.test.tsx b/src/components/ui/__tests__/sheet-dirty-check.browser.test.tsx new file mode 100644 index 000000000..e786b6756 --- /dev/null +++ b/src/components/ui/__tests__/sheet-dirty-check.browser.test.tsx @@ -0,0 +1,81 @@ +/** + * The sheet's fallback dirty check on two real capture forms, rendered in + * Chromium: untouched, neither may ask "Discard?" when the sheet closes, and + * each reads as edited once something is changed. + * + * - The symptom form's intensity slider sits in a `
`, so Radix adds + * an input of its own for it, which carries a value before anyone moves + * the slider. + * - The medication dose starts as the preselected medication's own dose. + * + * All data invented. + */ +import { describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { SymptomEntryForm } from "@/components/symptoms/symptom-entry-form"; +import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add"; +import { queryKeys } from "@/lib/query-keys"; + +import { stubFetch } from "../../../../tests/browser/fetch-stub"; +import { + renderWithApp, + testQueryClient, +} from "../../../../tests/browser/render"; +import { sheetBodyHasUnsavedInput } from "../responsive-sheet"; + +const bodyOf = (element: Element) => element as HTMLElement; + +describe("the sheet's dirty check on capture forms", () => { + it("reads an untouched symptom form as clean, a moved slider as input", async () => { + stubFetch().on("GET", /\/api\/symptoms\/definitions/, { + data: { definitions: [] }, + }); + const { screen } = await renderWithApp( +
+ +
, + ); + const slider = screen.getByRole("slider"); + await expect.element(slider).toBeInTheDocument(); + const body = bodyOf(screen.getByTestId("body").element()); + + expect(sheetBodyHasUnsavedInput(body)).toBe(false); + + await userEvent.click(slider); + await userEvent.keyboard("{ArrowRight}"); + expect(sheetBodyHasUnsavedInput(body)).toBe(true); + }); + + it("reads the preselected medication's dose as clean, an edited dose as input", async () => { + const queryClient = testQueryClient(); + queryClient.setQueryData(queryKeys.medications(), [ + { + id: "med-1", + name: "Example tablet", + dose: "500 mg", + active: true, + intakeActionable: true, + schedules: [], + lastTakenAt: null, + }, + ]); + stubFetch().on("GET", "/api/settings/reminder-thresholds", { + data: { lateMinutes: 60, missedMinutes: 240, lowStockRunwayDays: null }, + }); + const { screen } = await renderWithApp( +
+ +
, + { queryClient }, + ); + const dose = screen.getByTestId("medication-intake-quick-add-dose"); + await expect.element(dose).toHaveValue("500 mg"); + const body = bodyOf(screen.getByTestId("body").element()); + + expect(sheetBodyHasUnsavedInput(body)).toBe(false); + + await dose.fill("250 mg"); + expect(sheetBodyHasUnsavedInput(body)).toBe(true); + }); +}); diff --git a/src/components/ui/__tests__/sheet-dirty-check.test.ts b/src/components/ui/__tests__/sheet-dirty-check.test.ts index 80868167b..ffaeeff63 100644 --- a/src/components/ui/__tests__/sheet-dirty-check.test.ts +++ b/src/components/ui/__tests__/sheet-dirty-check.test.ts @@ -28,6 +28,7 @@ function fakeBody(fixture: { const fields = (fixture.fields ?? []).map((field) => ({ ...field, value: field.value ?? "", + dataset: {}, closest: () => (field.inDateOrTimeField ? {} : null), })); return { diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index ac634acf6..2733ae7a6 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -169,9 +169,12 @@ export function keepKeyboardDownOnOpen(event: Event): void { /** * The fallback dirty check for an add form that keeps its state inside the * sheet body: any text field with a value, a checkbox or radio moved off its - * default, a chosen `role="radio"` (the mood faces) or a Select that is open. - * The date and time fields are skipped, because the forms fill them with - * "now". An edit form starts filled and has to say for itself whether + * default, a slider moved off unanswered, a chosen `role="radio"` (the mood + * faces) or a Select that is open. The date and time fields are skipped, + * because the forms fill them with "now", and so is the input a slider keeps + * for its form. A field the form fills itself names that value in + * `data-seed` (a controlled input's `defaultValue` follows its value, so it + * cannot say). An edit form starts filled and has to say for itself whether * anything changed. */ export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean { @@ -181,7 +184,11 @@ export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean { ); for (const field of fields) { if (field.disabled || field.type === "hidden") continue; - if (field.closest('[data-slot="date-field"], [data-slot="time-field"]')) { + if ( + field.closest( + '[data-slot="date-field"], [data-slot="time-field"], [data-slot="slider"]', + ) + ) { continue; } if (field.type === "checkbox" || field.type === "radio") { @@ -189,7 +196,12 @@ export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean { if (box.checked !== box.defaultChecked) return true; continue; } - if (field.value.trim() !== "") return true; + if (field.value.trim() !== (field.dataset.seed ?? "").trim()) return true; + } + if ( + body.querySelector('[data-slot="slider-field"]:not([data-unset])') !== null + ) { + return true; } // Mood faces start with none chosen, so any chosen one is input. if (body.querySelector('[role="radio"][aria-checked="true"]') !== null) { From 7b6d518ca60aeb7d705a4453cfeb557101003dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 15:09:34 +0100 Subject: [PATCH 04/19] fix(mobile): coach padding with the keyboard up, chip remove targets - The Coach thread kept 80 px free under the last message for the New conversation button while the keyboard hid that button. - The remove buttons on lab alias chips and attached-document chips reached sideways into their own label and the next chip, and an alias is removed at once. Their tap area now stays inside the chip's gaps sideways and keeps its height. - A capture menu comment described a dialog that is gone. --- src/__tests__/baselines/version-stamp-comments.json | 2 +- .../insights/coach-panel/__tests__/message-thread.test.tsx | 4 +++- src/components/insights/coach-panel/message-thread.tsx | 5 +++-- src/components/labs/biomarker-aliases.tsx | 2 +- src/components/layout/capture-picker.tsx | 3 --- src/components/settings/share-link-create-form.tsx | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index b9b9a0eee..b305c94b7 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1088,7 +1088,7 @@ "components/layout/account-switcher-menu.tsx": 1, "components/layout/auth-shell.tsx": 13, "components/layout/bottom-nav.tsx": 8, - "components/layout/capture-picker.tsx": 4, + "components/layout/capture-picker.tsx": 3, "components/layout/nav-model.ts": 17, "components/layout/record-session-fence-bridge.tsx": 1, "components/layout/shared-record-banner.tsx": 1, diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index ea843d1aa..5142b85f3 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -258,8 +258,10 @@ describe("", () => { html.match(/]*data-slot="coach-message-thread"[^>]*>/)?.[0] ?? ""; expect(scroller).toContain("pb-20"); - // Back to the plain gutter once the button sits beside the composer. + // Back to the plain gutter once the button sits beside the composer, + // and while the keyboard hides it. expect(scroller).toContain("@min-[57rem]/coach-thread:pb-6"); + expect(scroller).toContain("keyboard-open:pb-6"); }); it("folds an older message's provenance into the thought-process row, never a raw key", () => { diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index d61fa1c4e..ddd6a20dc 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -657,8 +657,9 @@ export function MessageThread({ // bottom edge while the composer fills the column (`NewChatFab`), // so the last message keeps the button's height free, as the // shell clears the Coach button. The button moves beside a 54rem - // composer, which the column is at 57rem with its padding. - "pb-20 @min-[57rem]/coach-thread:pb-6", + // composer, which the column is at 57rem with its padding, and + // hides while the keyboard is up. + "keyboard-open:pb-6 pb-20 @min-[57rem]/coach-thread:pb-6", )} > {[ diff --git a/src/components/labs/biomarker-aliases.tsx b/src/components/labs/biomarker-aliases.tsx index 21be66623..6fffeaa93 100644 --- a/src/components/labs/biomarker-aliases.tsx +++ b/src/components/labs/biomarker-aliases.tsx @@ -90,7 +90,7 @@ export function BiomarkerAliases({ {canEdit ? (