diff --git a/src/app/globals.css b/src/app/globals.css index df02de4bce..720e46fc66 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1661,7 +1661,6 @@ summary::-webkit-details-marker { padding-right: max(1.75rem, calc(var(--safe-area-right) + 1rem)); } - .medication-also-matches, .medication-patient-strip { border-right: 0; border-left: 0; @@ -1669,7 +1668,13 @@ summary::-webkit-details-marker { box-shadow: none; } - .medication-also-matches, + /* Keep the raised-card treatment from UniversalSearchAlsoMatches on phones — + inset from the full-bleed workspace rather than flattening to a strip. */ + .medication-also-matches { + margin-left: max(1rem, calc(var(--safe-area-left) + 0.25rem)); + margin-right: max(1rem, calc(var(--safe-area-right) + 0.25rem)); + } + .medication-patient-strip > summary, .medication-patient-strip > div { padding-left: max(1.75rem, calc(var(--safe-area-left) + 1rem)); diff --git a/src/components/clinical-dashboard/universal-search-also-matches.tsx b/src/components/clinical-dashboard/universal-search-also-matches.tsx index 973356775a..e954ae0f10 100644 --- a/src/components/clinical-dashboard/universal-search-also-matches.tsx +++ b/src/components/clinical-dashboard/universal-search-also-matches.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Layers } from "lucide-react"; import { useEffect, useId, useState } from "react"; import { useFavouritesAccess } from "@/components/clinical-dashboard/use-favourites-access"; @@ -17,6 +17,10 @@ function isFavouritesHref(href: string) { return href === "/favourites" || href.startsWith("/favourites?"); } +function matchCountLabel(count: number) { + return count === 1 ? "1 related mode" : `${count} related modes`; +} + export function UniversalSearchAlsoMatches({ modeId, query, @@ -100,16 +104,32 @@ export function UniversalSearchAlsoMatches({ const currentGroups = universal.query === trimmedQuery ? groups : []; const searchPending = searchActive && (universal.loading || universal.query !== trimmedQuery); const panelStatus = searchPending ? "Searching other modes" : "No additional matches in other modes."; + const matchCount = currentGroups.length; + const phoneSubtitle = searchPending + ? "Searching…" + : !searchActive + ? "Tap to browse related modes" + : matchCount > 0 + ? matchCountLabel(matchCount) + : "No additional matches"; if (!viewportReady || trimmedQuery.length < 2) return null; if (modeId === "answer" && currentGroups.length === 0) return null; if (isWide && !searchPending && currentGroups.length === 0) return null; + // Count badge: ellipsis while collapsed/pending/empty so a finished-empty + // disclosure does not show a literal "0" next to "No additional matches". + const phoneCountBadge = !searchActive || searchPending || matchCount === 0 ? "…" : String(matchCount); + return (
- - Also matches in other modes - - {currentGroups.length || "…"} - + + - - Across Clinical KB + + + + Also matches in other modes + + + {phoneCountBadge} + + + {/* Visual cue only — keep the button name to the title (+ optional count). */} + + {phoneSubtitle} + - Across Clinical KB + + > +
{searchPending || currentGroups.length === 0 ? ( -

+

{panelStatus}

) : null} @@ -163,20 +203,20 @@ export function UniversalSearchAlsoMatches({ return (
- + - + {targetMode.label} {group.items.map((item) => ( {item.title} @@ -184,7 +224,9 @@ export function UniversalSearchAlsoMatches({ View all diff --git a/tests/helpers/style-contracts.ts b/tests/helpers/style-contracts.ts index 44e3f69263..a9d018783e 100644 --- a/tests/helpers/style-contracts.ts +++ b/tests/helpers/style-contracts.ts @@ -98,9 +98,9 @@ export function parseUnlayeredVisualClasses(css: string): UnlayeredVisualClass[] // Any line that opens a rule block, at-rules excluded. Deliberately NOT // "starts with a class": a selector list is often split across lines, and an // earlier line can carry a class the opening line does not - // (`.medication-also-matches,` above `.medication-patient-strip {`). Keying on - // the opening line alone silently left those classes unpoliced, so the gate - // could pass with an unregistered unlayered class. + // (`.edge-glass-header-backdrop,` above `.edge-glass-header-backdrop::before,` + // … `{`). Keying on the opening line alone silently left those classes + // unpoliced, so the gate could pass with an unregistered unlayered class. if (!line.trimEnd().endsWith("{") || /^\s*@/.test(line)) continue; if (insideLayer(index)) continue; @@ -285,8 +285,6 @@ export const STYLE_CONTRACT_EXEMPTIONS: Readonly> = { // Mode-specific surfaces. "differentials-mobile-compare-fab__button": "differentials compare FAB — no effect contract yet (#094)", "differentials-mobile-compare-fab__button--empty": "differentials compare FAB — no effect contract yet (#094)", - "medication-also-matches": - "prescribing also-matches row — the class Codex named as unpoliced; no effect contract yet (#094)", "medication-mobile-result": "prescribing phone results — no effect contract yet (#094)", "medication-mobile-results": "prescribing phone results — no effect contract yet (#094)", "medication-patient-strip": "prescribing patient strip — no effect contract yet (#094)", diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 790b4f5b4b..2814675b0b 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2130,10 +2130,20 @@ test.describe("Clinical KB UI smoke coverage", () => { const header = document.querySelector("header"); const surface = document.querySelector('[data-dashboard-stage="answer-surface"]'); const alsoMatches = document.querySelector('[data-testid="universal-also-matches"]'); + // Include vertical margins: the phone bottom clearance (`max-sm:mb-4`) sits + // outside getBoundingClientRect().height and still consumes scroll budget. + let alsoMatchesHeight = 0; + if (alsoMatches instanceof HTMLElement) { + const box = alsoMatches.getBoundingClientRect(); + const styles = window.getComputedStyle(alsoMatches); + alsoMatchesHeight = Math.ceil( + box.height + (Number.parseFloat(styles.marginTop) || 0) + (Number.parseFloat(styles.marginBottom) || 0), + ); + } return { headerBottom: header ? Math.round(header.getBoundingClientRect().bottom) : 0, surfaceTop: surface ? Math.round(surface.getBoundingClientRect().top) : 0, - alsoMatchesHeight: alsoMatches ? Math.ceil(alsoMatches.getBoundingClientRect().height) : 0, + alsoMatchesHeight, }; }); // Content-sized section => no unexplained phantom scroll. Submitted universal @@ -2142,7 +2152,8 @@ test.describe("Clinical KB UI smoke coverage", () => { // The responsive notice keeps its complete instruction while returning the // unexplained-scroll allowance to the original 8px phone contract. Submitted // universal matches are real content, so subtract their measured height - // before applying that phantom-overflow budget. + // before applying that phantom-overflow budget. That measured height already + // includes the section's phone bottom margin, so the 8px allowance stays put. const permittedOverflow = geo.alsoMatchesHeight + 8; expect(scrollGeometry.owner).toBe("document"); expect(scrollGeometry.maxScrollTop).toBeLessThanOrEqual(permittedOverflow);