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
57 changes: 57 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,63 @@ summary::-webkit-details-marker {
--footer-scrim-height: max(7rem, calc(var(--safe-area-bottom) + 5.5rem));
}

.differentials-mobile-compare-fab {
pointer-events: none;
position: fixed;
inset-inline: max(1rem, var(--safe-area-left)) max(1rem, var(--safe-area-right));
bottom: max(5.35rem, calc(var(--safe-area-bottom) + 5.05rem));
z-index: 50;
display: flex;
justify-content: center;
}

.differentials-mobile-compare-fab__button {
pointer-events: auto;
display: inline-flex;
min-height: 3.25rem;
max-width: min(100%, 36rem);
align-items: center;
gap: 0.625rem;
border-radius: 9999px;
border: 1px solid color-mix(in srgb, var(--clinical-accent), white 14%);
background:
radial-gradient(
circle at 82% 0%,
color-mix(in srgb, var(--clinical-accent-contrast) 20%, transparent),
transparent 34%
),
linear-gradient(135deg, color-mix(in srgb, var(--clinical-accent), white 7%), var(--clinical-accent));
padding: 0.375rem 0.625rem 0.375rem 1.25rem;
color: var(--clinical-accent-contrast);
font-size: 0.95rem;
font-weight: 900;
letter-spacing: -0.01em;
box-shadow:
0 18px 42px rgb(0 0 0 / 24%),
0 0 0 1px color-mix(in srgb, var(--clinical-accent-contrast) 10%, transparent) inset;
touch-action: manipulation;
transition:
transform 160ms ease,
background-color 160ms ease,
box-shadow 160ms ease;
}

.differentials-mobile-compare-fab__button:active {
transform: translateY(1px) scale(0.99);
}

.differentials-mobile-compare-fab__button:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 3px;
}

.differentials-mobile-compare-fab__button--empty {
border-color: var(--border-strong);
background: color-mix(in srgb, var(--surface) 92%, transparent);
color: var(--text-muted);
font-weight: 800;
}

/* Bottom-docked composer: hint row + upward dropdown grow above the pill. */
.answer-footer-search-edge {
overflow: visible;
Expand Down
22 changes: 13 additions & 9 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,12 @@ function DifferentialsMobileCompareBar({
const hasSelection = selectedCount > 0;

return createPortal(
<div aria-live="polite" className="flex w-full justify-center">
<div aria-live="polite" className="differentials-mobile-compare-fab">
{hasSelection ? (
<Link
href={routeWithQuery("/differentials/presentations", query, selectedIds)}
data-testid="differentials-compare-selected-mobile"
className="inline-flex min-h-12 max-w-full items-center gap-2.5 rounded-full border border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] py-1 pl-4 pr-2.5 text-sm font-extrabold text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-elevated)] transition active:bg-[color:var(--clinical-accent-hover)]"
className="differentials-mobile-compare-fab__button"
>
<GitCompareArrows className="h-5 w-5 shrink-0" aria-hidden />
<span className="truncate">Compare selected</span>
Expand All @@ -178,7 +178,7 @@ function DifferentialsMobileCompareBar({
) : (
<p
data-testid="differentials-compare-selected-mobile"
className="inline-flex min-h-12 max-w-full items-center gap-2.5 rounded-full border border-[color:var(--border-strong)] bg-[color:var(--surface)] px-4 text-sm font-bold text-[color:var(--text-muted)] shadow-[var(--shadow-elevated)]"
className="differentials-mobile-compare-fab__button differentials-mobile-compare-fab__button--empty"
>
<GitCompareArrows className="h-5 w-5 shrink-0 text-[color:var(--text-soft)]" aria-hidden />
<span className="truncate">Tick results to compare</span>
Expand Down Expand Up @@ -801,7 +801,7 @@ function SearchResultsView({
// for comparison and drop stale ids whenever a new query changes the results
// (render-time sync, matching the repo's set-state-in-render pattern).
const resultSignature = results.map((result) => result.id).join("|");
const [lastResultSignature, setLastResultSignature] = useState(resultSignature);
const [lastResultSignature, setLastResultSignature] = useState("");
if (lastResultSignature !== resultSignature) {
setLastResultSignature(resultSignature);
setKindFilter("all");
Expand Down Expand Up @@ -871,7 +871,11 @@ function SearchResultsView({
return (
<div
data-testid="differentials-search-results"
className="mx-auto grid w-full max-w-[86rem] gap-4 overflow-x-hidden px-3 pb-[calc(9rem+env(safe-area-inset-bottom))] sm:px-4 lg:px-0 lg:pb-0"
// overflow-x-clip (not hidden): hidden forces overflow-y to auto and turns
// this results canvas into a nested phone scrollport, stealing scroll from
// #main-content. The fixed compare FAB and shell hide-on-scroll both assume
// #main-content owns vertical scroll.
className="mx-auto grid w-full max-w-[86rem] min-w-0 gap-3 overflow-x-clip px-3 pb-[calc(10rem+env(safe-area-inset-bottom))] min-[390px]:gap-4 sm:px-4 lg:px-0 lg:pb-0"
>
{/* Query context lives here on every breakpoint — on phones this is the
only place the submitted query is visible above the fold. */}
Expand All @@ -885,10 +889,10 @@ function SearchResultsView({
/>
<p
data-testid="differentials-catalogue-notice"
className="flex items-start gap-2 rounded-lg border border-[color:var(--info-border)] bg-[color:var(--info-soft)]/50 px-3 py-1.5 text-xs font-semibold leading-5 text-[color:var(--info)] sm:py-2 sm:text-sm"
className="flex min-w-0 items-start gap-2 rounded-lg border border-[color:var(--info-border)] bg-[color:var(--info-soft)]/50 px-3 py-1.5 text-xs font-semibold leading-5 text-[color:var(--info)] sm:py-2 sm:text-sm"
>
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<span>
<span className="min-w-0 text-pretty">
Ranked from your imported differentials catalogue. Source counts reflect real matches from your indexed
library.
</span>
Expand Down Expand Up @@ -1034,7 +1038,7 @@ function SearchResultsView({
{!hasSourceEvidence && !sourcesChecked ? (
<section
aria-label="Source status"
className="flex items-center justify-between gap-2 rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)]/40 py-1.5 pl-3 pr-1.5 text-xs"
className="grid gap-2 rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)]/40 px-3 py-2 text-xs min-[390px]:flex min-[390px]:items-center min-[390px]:justify-between min-[390px]:gap-2 min-[390px]:py-1.5 min-[390px]:pr-1.5"
>
<p className="min-w-0 font-semibold leading-4 text-[color:var(--text-heading)]">
Sources not checked for this query yet.
Expand All @@ -1043,7 +1047,7 @@ function SearchResultsView({
type="button"
onClick={rerunSearch}
disabled={loading}
className="inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--surface)] px-2.5 text-xs font-extrabold text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:opacity-60"
className="inline-flex min-h-tap w-full shrink-0 items-center justify-center gap-1.5 rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--surface)] px-2.5 text-xs font-extrabold text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:opacity-60 min-[390px]:w-auto"
>
<Search className="h-3.5 w-3.5" aria-hidden />
{loading ? "Searching…" : "Run source search"}
Expand Down
8 changes: 8 additions & 0 deletions tests/mobile-interaction-regressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe("mobile interaction regressions", () => {
it("leaves phone vertical scrolling to the shared shell", () => {
const presentationSource = source("src/components/differentials/differential-presentation-workflow-page.tsx");
const favouritesSource = source("src/components/clinical-dashboard/favourites-command-library-page.tsx");
const differentialsHomeSource = source("src/components/clinical-dashboard/differentials-home.tsx");

expect(presentationSource).toMatch(
/data-testid="differential-presentation-page"\s+className="[^"]*min-h-0[^"]*overflow-x-clip[^"]*sm:min-h-\[calc\(100dvh-4rem\)\]/,
Expand All @@ -32,6 +33,13 @@ describe("mobile interaction regressions", () => {
/data-testid="favourites-hub"\s+className="[^"]*min-h-0[^"]*overflow-x-clip[^"]*sm:min-h-\[calc\(100dvh-4rem\)\]/,
);
expect(favouritesSource).toContain('"grid min-h-0 min-w-0 overflow-x-clip sm:min-h-[calc(100dvh-4rem)]"');
// overflow-x-hidden would force overflow-y:auto and nest a scrollport under #main-content.
expect(differentialsHomeSource).toMatch(
/data-testid="differentials-search-results"[\s\S]*?className="[^"]*overflow-x-clip[^"]*"/,
);
expect(differentialsHomeSource).not.toMatch(
/data-testid="differentials-search-results"[\s\S]*?className="[^"]*overflow-x-hidden[^"]*"/,
);
});

it("keeps phone quick actions and the privacy link at the semantic tap size", () => {
Expand Down
6 changes: 4 additions & 2 deletions tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1455,8 +1455,8 @@ test.describe("Clinical KB tools launcher", () => {
await expect(compareAction).toBeVisible();
await expect(compareAction).toContainText("Compare selected");

await scrollport.evaluate((element) => element.scrollTo({ top: 700, behavior: "instant" }));
await expect.poll(() => scrollport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
await mainContent.evaluate((element) => element.scrollTo({ top: 700, behavior: "instant" }));
await expect.poll(() => mainContent.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
await page.waitForTimeout(300);

await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true");
Expand Down Expand Up @@ -1492,6 +1492,8 @@ test.describe("Clinical KB tools launcher", () => {
expect(compareGeometry.top).toBeGreaterThanOrEqual(0);
expect(compareGeometry.bottom).toBeLessThanOrEqual(compareGeometry.viewportHeight);
expect(compareGeometry.receivesPointer).toBe(true);
expect(compareGeometry.bottom).toBeLessThan(dockTop);
await expectNoPageHorizontalOverflow(page);

// The result cards and compare bar remain in their non-desktop layout up
// to 1023px, so the composer must keep providing the portal host on tablet.
Expand Down