Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 27 additions & 17 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ export function ClinicalDashboard({
// overlay); other modes keep the phone-only collapse, so the reporter only
// widens past the phone media gate while in answer mode.
const phoneScrollHide = useScrollHideReporter(false, searchMode === "answer");
const [bottomComposerHidden, setBottomComposerHidden] = useState(false);
const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll);
reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll;
const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() =>
Expand Down Expand Up @@ -3249,6 +3250,20 @@ export function ClinicalDashboard({
const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted;
const differentialsCompareAddonActive =
searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim());
const visibleMobileComposerReserve =
searchMode === "answer"
? answerFollowUpSuggestions.length > 0
? "calc(7.5rem + var(--safe-area-bottom))"
: "calc(5.25rem + var(--safe-area-bottom))"
: differentialsCompareAddonActive
? "calc(8.75rem + var(--safe-area-bottom))"
: compactMobileBottomSearch
? "calc(5rem + var(--safe-area-bottom))"
: "calc(5.25rem + var(--safe-area-bottom))";
// Browser safe areas protect the visible interactive dock. Once it has
// scrolled away, reserving Safari's translucent toolbar inset would leave a
// large blank band instead of allowing ordinary content to paint beneath it.
const mobileComposerReserve = bottomComposerHidden ? "0.75rem" : visibleMobileComposerReserve;
const renderDegradedNotice = () => (
<UtilityDrawer
icon={!isOnline ? WifiOff : CircleAlert}
Expand Down Expand Up @@ -3445,6 +3460,7 @@ export function ClinicalDashboard({
{
"--clinical-sidebar-width": sidebarCollapsed ? "5.25rem" : "20rem",
"--clinical-sidebar-width-md": "5.25rem",
"--mobile-composer-reserve": mobileComposerReserve,
} as CSSProperties
}
>
Expand Down Expand Up @@ -3529,6 +3545,7 @@ export function ClinicalDashboard({
? { strategy: "overlay", allBreakpoints: true, scrollHidden: phoneScrollHide.hidden }
: { strategy: "collapse", scrollHidden: phoneScrollHide.hidden }
}
onBottomComposerHiddenChange={setBottomComposerHidden}
/>

<main
Expand All @@ -3553,24 +3570,17 @@ export function ClinicalDashboard({
searchMode === "answer"
? compactMobileModeHome
? "mb-0"
: // Phone answer view: the "Ask a follow-up" dock is fixed to the
// bottom, so <main> reserves room for it. Keep that geometry stable
// while the dock translates off-screen: changing the flex item's
// margin alters its client height and clamps scrollTop near the
// bottom, feeding a false upward movement into hide-on-scroll.
answerFollowUpSuggestions.length > 0
? "mb-[calc(7.5rem+env(safe-area-inset-bottom))] sm:mb-24"
: "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-24"
: // Keep the phone scrollport edge-to-edge and reserve the visible
// dock inside its scrollable content. Padding can collapse when the
// dock hides without exposing the app-shell background; the
// bottom-clamp guard in use-hide-on-scroll prevents false reveals.
"max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-24"
: hasMobileBottomSearch
? compactMobileBottomSearch
? differentialsCompareAddonActive
? "mb-[calc(8.75rem+env(safe-area-inset-bottom))] sm:mb-0"
: "mb-[calc(5rem+env(safe-area-inset-bottom))] sm:mb-0"
: // Mode homes keep the composer in the hero (in-flow at every
// width), so phones need no bottom-dock clearance on them.
compactMobileModeHome || showDesktopHomeComposer
? "mb-0"
: "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-0"
? // Mode homes keep the composer in the hero (in-flow at every
// width), so phones need no bottom-dock clearance on them.
compactMobileModeHome || showDesktopHomeComposer
? "mb-0"
: "max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-0"
: "mb-0",
)}
>
Expand Down
11 changes: 10 additions & 1 deletion src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2681,7 +2681,16 @@ export function DocumentViewer({
</Sheet>
) : null}

<section className="mx-auto grid max-w-[1440px] gap-4 px-3 py-4 pb-36 sm:gap-5 sm:px-4 sm:py-5 sm:pb-40 lg:grid-cols-[minmax(0,1fr)_480px] lg:items-start lg:px-8">
<section
data-testid="document-viewer-content"
className={cn(
"mx-auto grid max-w-[1440px] gap-4 px-3 py-4 sm:gap-5 sm:px-4 sm:py-5 sm:pb-40 lg:grid-cols-[minmax(0,1fr)_480px] lg:items-start lg:px-8",
// The visible fixed composer needs endpoint clearance. Once hidden,
// keep only a small content pad so Safari can paint document content
// beneath its translucent toolbar instead of showing a blank band.
composerScrollHidden ? "max-sm:pb-3" : "max-sm:pb-[calc(9rem+var(--safe-area-bottom))]",
)}
>
{downloadError ? (
<InlineNotice tone="warning" className="lg:col-span-2">
{downloadError}
Expand Down
18 changes: 9 additions & 9 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,21 +284,21 @@ function GlobalStandaloneSearchShellClient({
: isStandaloneModeHome
? "2rem"
: searchMode === "answer"
? "calc(9rem + env(safe-area-inset-bottom))"
? "calc(9rem + var(--safe-area-bottom))"
: differentialsCompareAddonActive
? "calc(8.75rem + env(safe-area-inset-bottom))"
? "calc(8.75rem + var(--safe-area-bottom))"
: useCompactBottomSearch
? "calc(5.5rem + env(safe-area-inset-bottom))"
: "calc(9rem + env(safe-area-inset-bottom))";
? "calc(5.5rem + var(--safe-area-bottom))"
: "calc(9rem + var(--safe-area-bottom))";
// Release the large bottom reserve only when the phone bottom composer is
// actually hidden (MasterSearchHeader's bottomComposerHidden). Header-only
// scroll-hide, pinned compare addons, open menus/sheets, and composer focus
// keep the full reserve so content does not slide under a still-visible dock.
// When the dock does hide, keep only a small safe-area breathing room so
// content — not the background — fills the revealed edge-to-edge viewport.
const mobileComposerReserve = bottomComposerHidden
? "max(0.75rem, env(safe-area-inset-bottom))"
: visibleMobileComposerReserve;
// Safari's bottom safe-area inset includes its translucent browser toolbar.
// Reusing that inset after the app composer hides recreates a toolbar-sized
// blank band, so the hidden state intentionally keeps only a small content
// pad. Interactive composer chrome still receives the full inset above.
const mobileComposerReserve = bottomComposerHidden ? "0.75rem" : visibleMobileComposerReserve;

useEffect(() => {
// Re-derive the mode and query from the URL, but only when the search string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1206,7 +1206,7 @@ export function MasterSearchHeader({
// Keep that dock pinned while the header can still collapse independently.
const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock && !mobileBottomSearchAddonSlotId);
// Phone submitted non-answer result docks reserve pill-only scroll
// clearance (ClinicalDashboard <main> margins / global-search-shell
// clearance (ClinicalDashboard / global-search-shell <main> padding via
// mobileComposerReserve), so an extra notice line would push the fixed
// dock over the last result. Those flows already showed the notice on
// their entry composer; answer docks keep it (their reserves were sized
Expand Down
11 changes: 10 additions & 1 deletion tests/clinical-dashboard-merge-artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const globalSearchShellSource = readFileSync(
"utf8",
);
const clinicalDashboardSource = readFileSync(resolve(process.cwd(), "src/components/ClinicalDashboard.tsx"), "utf8");
const documentViewerSource = readFileSync(resolve(process.cwd(), "src/components/DocumentViewer.tsx"), "utf8");
const globalStylesSource = readFileSync(resolve(process.cwd(), "src/app/globals.css"), "utf8");

type FoundDeclaration = { source: string };
Expand Down Expand Up @@ -86,11 +87,19 @@ describe("ClinicalDashboard merge-artifact guards", () => {
expect(globalStylesSource).not.toMatch(/^\s*-webkit-backdrop-filter\s*:/m);
});

it("reserves phone space for the fixed mode-home composer", () => {
it("releases the Safari toolbar reserve only after phone composers hide", () => {
expect(globalSearchShellSource).toContain("const visibleMobileComposerReserve = !shouldShowSearchComposer");
expect(globalSearchShellSource).toContain("const mobileComposerReserve = bottomComposerHidden");
expect(globalSearchShellSource).toContain('bottomComposerHidden ? "0.75rem"');
expect(globalSearchShellSource).not.toContain("max(0.75rem, env(safe-area-inset-bottom))");
expect(globalSearchShellSource).not.toContain("const mobileComposerReserve = !reservesFloatingComposer");
expect(globalSearchShellSource).not.toContain("const mobileComposerReserve = phoneScrollHide.hidden");
expect(clinicalDashboardSource).toContain('bottomComposerHidden ? "0.75rem"');
expect(clinicalDashboardSource).toContain('"max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-24"');
expect(documentViewerSource).toContain('data-testid="document-viewer-content"');
expect(documentViewerSource).toContain('"max-sm:pb-3"');
expect(documentViewerSource).toContain('"max-sm:pb-[calc(9rem+var(--safe-area-bottom))]"');
expect(globalStylesSource).toMatch(/--safe-area-bottom\s*:/);
});

it("does not reintroduce the obsolete output-mode copy helper", () => {
Expand Down
128 changes: 95 additions & 33 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,13 @@ async function openScopeControl(page: Page) {
.catch(() => undefined);

const composer = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible').first();
const bottomDock = page.locator("form.answer-footer-search-dock");
if (await bottomDock.isVisible().catch(() => false)) {
// Prior sheet/scroll interactions can leave the phone dock translated off-screen.
// Restore it before opening scope so the click lands in the viewport.
await scrollPrimarySurface(page, 0);
await expect(bottomDock).not.toHaveAttribute("data-scroll-hidden", "true");
}

await composer.click();
const scopeOption = page.getByRole("option", { name: /Scope sources/i });
Expand Down Expand Up @@ -1914,10 +1921,20 @@ test.describe("Clinical KB UI smoke coverage", () => {
// Wait for streaming to finish (deterministic) so the geometry below reads the
// final, settled layout — replaces a fixed 400ms sleep.
await expect(page.getByTestId("answer-streaming")).toHaveCount(0);
// Start from the top so the assertions describe the resting, top-aligned view.
await page.locator("main#main-content").evaluate((el) => {
el.scrollTop = 0;
// Apply the Safari toolbar simulation after answer navigation has settled;
// the submit flow may update the URL and replace earlier document styles.
await page.evaluate(() => {
document.documentElement.style.setProperty("--safe-area-bottom", "112px");
});
const main = page.locator("main#main-content");
const bottomDock = page.locator("form.answer-footer-search-dock");
// Start from the top so the assertions describe the resting, top-aligned
// view and the hide reporter has observed the restored position.
await scrollPrimarySurface(page, 0);
await expect(bottomDock).not.toHaveAttribute("data-scroll-hidden", "true");
await expect
.poll(async () => main.evaluate((el) => Number.parseFloat(window.getComputedStyle(el).paddingBottom)))
.toBeGreaterThan(200);

const geo = await page.evaluate(() => {
const main = document.querySelector("main#main-content");
Expand All @@ -1927,46 +1944,61 @@ test.describe("Clinical KB UI smoke coverage", () => {
scrollHeight: main?.scrollHeight ?? 0,
clientHeight: main?.clientHeight ?? 0,
mainBottom: main ? Math.round(main.getBoundingClientRect().bottom) : 0,
mainMarginBottom: main ? Number.parseFloat(window.getComputedStyle(main).marginBottom) : -1,
mainPaddingBottom: main ? Number.parseFloat(window.getComputedStyle(main).paddingBottom) : 0,
viewportHeight: window.innerHeight,
headerBottom: header ? Math.round(header.getBoundingClientRect().bottom) : 0,
surfaceTop: surface ? Math.round(surface.getBoundingClientRect().top) : 0,
};
});
// A long answer overflows and scrolls, still top-aligned under the header.
expect(geo.scrollHeight).toBeGreaterThan(geo.clientHeight + 40);
expect(geo.surfaceTop - geo.headerBottom).toBeLessThanOrEqual(160);
// The <main> reserve keeps the opaque composer input fully below the scroll
// viewport, so answer content clears the composer instead of being hidden
// behind it. (The dock's blur scrim intentionally fades content above the
// input, so we anchor on the input itself, not the scrim's bounding box.)
// The scrollport itself remains edge-to-edge. Its content padding—not an
// outer margin—keeps the answer endpoint clear of the visible composer and
// Safari toolbar.
const composerInputTop = await visibleQuestionInput(page).evaluate((el) =>
Math.round(el.getBoundingClientRect().top),
);
expect(composerInputTop).toBeGreaterThanOrEqual(geo.mainBottom - 4);

// Hiding the fixed dock must not change the scrollable geometry. If its
// clearance is removed at the same time, reaching the bottom clamps
// scrollTop upward; hide-on-scroll reads that forced movement as an upward
// gesture and repeatedly restores/hides the dock, producing visible jitter.
const main = page.locator("main#main-content");
const bottomDock = page.locator("form.answer-footer-search-dock");
expect(geo.mainMarginBottom).toBe(0);
expect(Math.abs(geo.mainBottom - geo.viewportHeight)).toBeLessThanOrEqual(1);
expect(geo.mainPaddingBottom).toBeGreaterThan(112);
expect(geo.mainPaddingBottom + 4).toBeGreaterThanOrEqual(geo.mainBottom - composerInputTop);

// Once the fixed dock is actually hidden, release both the composer and
// Safari toolbar reserve. The scrollport dimensions stay stable while its
// bottom padding contracts; the bottom-clamp guard must keep the dock from
// immediately reappearing as a false upward gesture. Do not compare total
// scrollHeight here because universal matches can finish streaming while
// this test moves the scrollport.
const scrollGeometryBeforeHide = await main.evaluate((el) => ({
clientHeight: el.clientHeight,
paddingBottom: Number.parseFloat(window.getComputedStyle(el).paddingBottom),
}));
// WebKit retains focus on the submitted composer more aggressively than
// Chromium. Move focus to the scroll surface to model the user dismissing
// the composer before scrolling; focused composer chrome must stay visible.
await expect(async () => {
await main.focus();
await scrollPrimarySurface(page, 0);
await expect(bottomDock).not.toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 });
for (const offset of [120, 240, 360]) {
await scrollPrimarySurface(page, offset);
}
await expect(bottomDock).toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 });
}).toPass({ timeout: 15_000 });
await expect
.poll(async () => main.evaluate((el) => Number.parseFloat(window.getComputedStyle(el).paddingBottom)))
.toBeLessThanOrEqual(13);
const scrollGeometryAfterHide = await main.evaluate((el) => ({
clientHeight: el.clientHeight,
scrollHeight: el.scrollHeight,
paddingBottom: Number.parseFloat(window.getComputedStyle(el).paddingBottom),
}));
for (const offset of [120, 240, 360]) {
await main.evaluate((el, top) => {
el.scrollTop = top;
}, offset);
}
expect(scrollGeometryBeforeHide.paddingBottom).toBeGreaterThan(200);
expect(scrollGeometryAfterHide.clientHeight).toBe(scrollGeometryBeforeHide.clientHeight);
expect(scrollGeometryAfterHide.scrollHeight).toBeGreaterThan(scrollGeometryAfterHide.clientHeight);
await expect(bottomDock).toHaveAttribute("data-scroll-hidden", "true");
await expect
.poll(async () =>
main.evaluate((el) => ({
clientHeight: el.clientHeight,
scrollHeight: el.scrollHeight,
})),
)
.toEqual(scrollGeometryBeforeHide);
await expectNoPageHorizontalOverflow(page);
});

Expand Down Expand Up @@ -3496,6 +3528,15 @@ test.describe("Clinical KB UI smoke coverage", () => {
// must restore it before the explicit hide-on-scroll checks below.
await scrollPrimarySurface(page, 0);
await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true");
await page.evaluate(() => {
document.documentElement.style.setProperty("--safe-area-bottom", "112px");
});
const viewerContent = page.getByTestId("document-viewer-content");
await expect
.poll(async () =>
viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)),
)
.toBeGreaterThan(250);

const main = page.locator("#main-content");
await waitForReactEventHandler(main, "onScroll");
Expand All @@ -3507,21 +3548,42 @@ test.describe("Clinical KB UI smoke coverage", () => {
(main ?? window.document.body).appendChild(spacer);
});

// Hide on deliberate scroll down past the activation offset.
for (const offset of [40, 80, 120, 160, 200]) {
await scrollPrimarySurface(page, offset);
}
await expect(composer).toHaveAttribute("data-scroll-hidden", "true");
// Hide on deliberate scroll down past the activation offset. The chunk
// deep-link effect can finish late in Chromium and move the scrollport once
// more, so treat reset + deliberate movement as one retriable action.
await expect(async () => {
await scrollPrimarySurface(page, 0);
await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 });
for (const offset of [40, 80, 120, 160, 200]) {
await scrollPrimarySurface(page, offset);
}
await expect(composer).toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 });
}).toPass({ timeout: 15_000 });
await expect
.poll(async () =>
viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)),
)
.toBeLessThanOrEqual(13);

// Reappear on scroll up.
await scrollPrimarySurface(page, 60);
await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true");
await expect
.poll(async () =>
viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)),
)
.toBeGreaterThan(250);

// Keyboard focus inside the composer reveals it while hidden.
await scrollPrimarySurface(page, 240);
await expect(composer).toHaveAttribute("data-scroll-hidden", "true");
await composer.locator("input").focus();
await expect(composer).not.toHaveAttribute("data-scroll-hidden", "true");
await expect
.poll(async () =>
viewerContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)),
)
.toBeGreaterThan(250);
});

test("document questions use the shared answer stream with progress and cleaned bold formatting", async ({
Expand Down
Loading