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
4 changes: 4 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const projectRoot = path.dirname(fileURLToPath(import.meta.url));
const securityHeaders = buildSecurityHeaders(resolveRuntimeFlags());

const nextConfig: NextConfig = {
// Playwright and some local tooling hit the dev server via 127.0.0.1; without
// this, Next blocks HMR/client hydration from that host and phone scroll-hide
// never wires up its listeners.
allowedDevOrigins: ["127.0.0.1"],
devIndicators: false,
experimental: {
cpus: 1,
Expand Down
6 changes: 5 additions & 1 deletion scripts/playwright-base-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ function tryVerifiedLocalProjectUrl(baseUrl: string) {

function findExistingLocalProjectUrl() {
const stablePort = stableProjectPort(projectRoot);
return tryVerifiedLocalProjectUrl(`http://127.0.0.1:${stablePort}`);
// Prefer localhost so dev HMR matches ensure-local-server's printed URL.
return (
tryVerifiedLocalProjectUrl(`http://localhost:${stablePort}`) ??
tryVerifiedLocalProjectUrl(`http://127.0.0.1:${stablePort}`)
);
}

export function getPlaywrightBaseUrl() {
Expand Down
37 changes: 32 additions & 5 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
WifiOff,
Wrench,
} from "lucide-react";
import { type CSSProperties, type UIEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type DocumentDeleteResult } from "@/components/DocumentManagementActions";
import { extractSafetyFindings } from "@/lib/clinical-safety";
import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity";
Expand All @@ -38,8 +38,8 @@
primaryControl,
textMuted,
toneInfo,
toneSuccess,

Check warning on line 41 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions / verify

'toneSuccess' is defined but never used
toneWarning,

Check warning on line 42 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions / verify

'toneWarning' is defined but never used
} from "@/components/ui-primitives";
import { useAuthSession } from "@/lib/supabase/client";
import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog";
Expand Down Expand Up @@ -670,7 +670,14 @@
const router = useRouter();
const searchParams = useSearchParams();
const mainRef = useRef<HTMLElement>(null);
const [mainScrollRoot, setMainScrollRoot] = useState<HTMLElement | null>(null);
const assignMainRef = useCallback((node: HTMLElement | null) => {
mainRef.current = node;
setMainScrollRoot(node);
}, []);
const phoneScrollHide = useScrollHideReporter();
const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll);
reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll;
const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false);
const composerInputRef = useRef<HTMLInputElement>(null);
const scrollFrameRef = useRef<number | null>(null);
Expand Down Expand Up @@ -2655,11 +2662,31 @@
});
}

function handleMainScroll(event: UIEvent<HTMLElement>) {
function handleMainScroll() {
scheduleActiveSectionSync();
phoneScrollHide.reportScroll(event.currentTarget.scrollTop);
}

useEffect(() => {
const main = mainScrollRoot;
if (!main) return undefined;

let frame = 0;
const onScroll = () => {
if (frame) return;
frame = window.requestAnimationFrame(() => {
frame = 0;
reportPhoneScrollHideRef.current(main.scrollTop);
});
};

onScroll();
main.addEventListener("scroll", onScroll, { passive: true });
return () => {
main.removeEventListener("scroll", onScroll);
if (frame) window.cancelAnimationFrame(frame);
};
}, [mainScrollRoot]);

async function copyText(action: string, text: string) {
let copied = false;
try {
Expand Down Expand Up @@ -3122,13 +3149,13 @@
heroComposerFromTablet={Boolean(desktopHomeComposerSlotId)}
// Phone-only: the header sits above the internally scrolling <main>,
// so hiding must collapse its layout space to hand it to content.
hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden, containerRef: mainRef }}
hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden }}
onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden}
/>

<main
id="main-content"
ref={mainRef}
ref={assignMainRef}
tabIndex={-1}
onScroll={handleMainScroll}
className={cn(
Expand Down
15 changes: 6 additions & 9 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
type Ref,
type RefObject,
} from "react";
import { createPortal } from "react-dom";

Expand Down Expand Up @@ -244,11 +243,10 @@ export function MasterSearchHeader({
* content already flows beneath); "collapse" also releases the header's
* layout space (host keeps the header above an internally scrolling element).
* The phone bottom search composer hides in sync on search-mode pages.
* `containerRef` points at the scrolling element; omit it to observe window scroll. */
* Parent hosts with an internally scrolling element pass `scrollHidden` from
* `useScrollHideReporter` wired to that element's scroll events. */
hideOnScroll?: {
strategy: "overlay" | "collapse";
containerRef?: RefObject<HTMLElement | null>;
scrollContainer?: HTMLElement | null;
/** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */
scrollHidden?: boolean;
};
Expand Down Expand Up @@ -295,12 +293,9 @@ export function MasterSearchHeader({
const [headerChromeFocused, setHeaderChromeFocused] = useState(false);
const [composerChromeFocused, setComposerChromeFocused] = useState(false);
const internalScrollHidden = useHideOnScroll({
containerRef: hideOnScroll?.containerRef,
scrollContainer: hideOnScroll?.scrollContainer,
disabled: !hideOnScroll,
disabled: !hideOnScroll || hideOnScroll.scrollHidden !== undefined,
});
const scrollHidden =
hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden || internalScrollHidden : internalScrollHidden;
const scrollHidden = hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden : internalScrollHidden;
const headerChromeHidden =
scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused;
const phoneBottomSearchDockActive =
Expand Down Expand Up @@ -1560,6 +1555,8 @@ export function MasterSearchHeader({
// never carries a transform, and everything is inert from sm up.
return (
<div
data-scroll-hidden={headerChromeHidden ? "true" : undefined}
data-testid="universal-header-collapse"
className={cn(
"max-sm:grid max-sm:transition-[grid-template-rows] max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none",
headerChromeHidden ? "max-sm:[grid-template-rows:0fr]" : "max-sm:[grid-template-rows:1fr]",
Expand Down
35 changes: 35 additions & 0 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2191,6 +2191,41 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expectNoPageHorizontalOverflow(page);
});

test("phone universal header fully hides while scrolling dashboard main on phones", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await gotoApp(page, "/?mode=answer");

const header = page.locator("header.universal-header");
const collapseHost = page.getByTestId("universal-header-collapse");
await expect(header).toBeVisible();
await expect(collapseHost).not.toHaveAttribute("data-scroll-hidden", "true");
await expect.poll(async () => header.evaluate((node) => window.getComputedStyle(node).position)).toBe("relative");

const main = page.locator("main#main-content");
await main.evaluate((node) => {
const spacer = document.createElement("div");
spacer.setAttribute("data-testid", "header-hide-scroll-spacer");
spacer.style.height = "2000px";
node.appendChild(spacer);
});
// Step scroll down so the dashboard main listener sees deliberate movement.
for (const offset of [40, 80, 120, 160, 200]) {
await main.evaluate((node, top) => {
node.scrollTop = top;
}, offset);
}

await expect(collapseHost).toHaveAttribute("data-scroll-hidden", "true");
await expect
.poll(async () =>
header.evaluate((node) => {
const rect = node.getBoundingClientRect();
return Math.max(0, rect.bottom) - Math.max(0, rect.top);
}),
)
.toBe(0);
});

test("document viewer bottom composer hides while scrolling down on phones", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockDemoApi(page);
Expand Down
Loading