diff --git a/docs/branch-review-records/19cdca49e6751aaf2914f2d6ee4ab144b1689c076eb51580aff9534f27b98c65.record.md b/docs/branch-review-records/19cdca49e6751aaf2914f2d6ee4ab144b1689c076eb51580aff9534f27b98c65.record.md new file mode 100644 index 0000000000..940ff0250e --- /dev/null +++ b/docs/branch-review-records/19cdca49e6751aaf2914f2d6ee4ab144b1689c076eb51580aff9534f27b98c65.record.md @@ -0,0 +1 @@ +| 2026-08-18 | claude/clinical-guide-footer-search-4l54hp | a1f272a75d0ba878106687738cdf988884f89331 | Guide tour action rendered as a dock addon pill on phones | approved | verify:pr-local all stages green on the merged base; 673 test files, 7283 tests | diff --git a/docs/branch-review-records/5ef27bddd67e7684747a62117c9d75bf90397fb061f937dc1df32ca05c31fbbc.record.md b/docs/branch-review-records/5ef27bddd67e7684747a62117c9d75bf90397fb061f937dc1df32ca05c31fbbc.record.md new file mode 100644 index 0000000000..4e4ea330dc --- /dev/null +++ b/docs/branch-review-records/5ef27bddd67e7684747a62117c9d75bf90397fb061f937dc1df32ca05c31fbbc.record.md @@ -0,0 +1 @@ +| 2026-08-18 | claude/clinical-guide-footer-search-4l54hp | 8493f10a6489ebaa8ef1cabcd23b6c5cd0913b64 | Guide Centre footer composer shared phone dock chrome | approved | verify:pr-local all green; verify:phone-chrome static and unit stages green; focused-browser stage blocked by Playwright revision drift | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 5b3dde031f..b80800725b 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1613,6 +1613,8 @@ "tests/document-filter-panel.dom.test.tsx", "tests/document-organization.test.ts", "tests/factsheet-save.dom.test.tsx", + "tests/guide-centre-chrome.spec.ts", + "tests/guide-centre-design-contract.dom.test.tsx", "tests/header-scroll-hide-contract.test.ts", "tests/image-lightbox-geometry.test.ts", "tests/in-page-nav-route-sections.dom.test.tsx", diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 2e5e20fb5d..9758bc2fd3 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -4,17 +4,18 @@ This repo uses one shared search experience across the global shell, dashboard r ## Page ownership model -| Page state | Composer placement | Reserve owner | -| --------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| Shared home (`/`, any mode) / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | -| Tools directory (`/tools`, no submitted query) | Compact bottom dock on phones; in-flow hero composer from `sm` | Shell dock reserve on phones; page content from `sm` | -| Submitted/search-result views | Compact bottom dock on phones; in normal page flow on tablets and desktops | Shell/dashboard `--mobile-composer-reserve` on phones; page content on desktop | -| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | -| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | -| Document section navigation | Header row disclosure (phone sheet) + rail index card at `lg` | None — adds no chrome and no reserve | -| Record page breadcrumb header | Same header row without the disclosure or track; view mode inline from `sm` | None — portals into the phone collapse row, sticky at `sm+` | -| Calculators (`/calculators`) | In-flow hero composer at home; shared compact dock after submission | Page content at home; shell reserve for submitted results | -| Info/detail pages with no composer | No fixed composer | Idle shell padding only | +| Page state | Composer placement | Reserve owner | +| --------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Shared home (`/`, any mode) / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | +| Tools directory (`/tools`, no submitted query) | Compact bottom dock on phones; in-flow hero composer from `sm` | Shell dock reserve on phones; page content from `sm` | +| Submitted/search-result views | Compact bottom dock on phones; in normal page flow on tablets and desktops | Shell/dashboard `--mobile-composer-reserve` on phones; page content on desktop | +| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | +| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | +| Document section navigation | Header row disclosure (phone sheet) + rail index card at `lg` | None — adds no chrome and no reserve | +| Record page breadcrumb header | Same header row without the disclosure or track; view mode inline from `sm` | None — portals into the phone collapse row, sticky at `sm+` | +| Calculators (`/calculators`) | In-flow hero composer at home; shared compact dock after submission | Page content at home; shell reserve for submitted results | +| Info/detail pages with no composer | No fixed composer | Idle shell padding only | +| Guide Centre dialog (`GuideDialog`) | Shared edge-to-edge phone dock inside the Sheet footer; Sheet footer band from `sm` | `[data-guide-content]` bottom pad (`guide-search-dock` reserve owner) | The Tools row is scoped to the **mounted Tools directory**, not to `resultKind: "tools"`. Factsheets, Dictionary and Therapy Compass borrow that result kind purely as a benign search kind, and on the @@ -23,6 +24,41 @@ shared-home row above. `shouldShowSharedHome` already excludes `mode=tools`, whi `showSharedHome` is the correct opt-back-in for `heroComposerBreakpoint` and `centeredModeHome` in `ClinicalDashboard.tsx`. +### The Guide Centre footer is a dock, not a footer band + +`GuideDialog` renders its search composer through the shared `Sheet` footer slot, and +`Sheet` always wraps that slot in `border-t border-[color:var(--border)] p-3 sm:p-4` +(`src/components/ui/sheet.tsx`). On phones that band is the wrong chrome: an opaque +`--surface-raised` slab with a hard top border reads as a cover over the content behind +the composer, which is exactly what every other phone composer avoids. + +So the guide footer carries `answer-footer-search-dock answer-footer-search-edge` and +renders one `.answer-footer-search-backdrop` child, the same pair the shell dock uses. +`globals.css` then owns the phone geometry — flush `left/right/bottom: 0`, safe-area +padding, `background: transparent` — and the scrim tints only around the pill before +tapering to zero at the physical edge. The band's own border, surface and elevation are +`sm:` only, and the scrim is `sm:hidden`, so the tablet/desktop dialog footer is +unchanged. + +Two consequences worth keeping: + +- The dock stays on the **default** scrim height, not `document-mobile-search-compact`: + the tour action row sits above the pill, the same shape the differentials-compare and + patient-details dock addons take, and the compact 5rem scrim would end mid-row. +- The footer wrapper is the dock element, so its children need `relative z-10` to paint + above the scrim. +- The band's glass and the addon pill are proven in a **browser**, not by class presence: + `tests/guide-centre-chrome.spec.ts` asserts the painted background, border, flush + geometry and scrim, plus the pill's rendered border/radius/alpha. jsdom cannot evaluate + the `max-sm:` media query, and tailwind-merge keeps both the base and the variant + utility, so stylesheet order — not the class list — decides which one wins. +- The tour action is a dock **addon**, so on phones it takes the addon-pill treatment the + other two addons use — `patient-details-fab__button` and Compare's quiet + `--empty` state: an outlined translucent pill, never a filled primary control. A filled + button there puts back a smaller version of the cover the dock conversion removed, + because the band behind it is transparent by design. Those overrides are `max-sm:` + only; from `sm` the footer is a real band and the primary treatment is correct. + ## Default in-page navigation template When adding or suggesting **in-page navigation** on any mode page, use the DocumentViewer diff --git a/src/components/clinical-dashboard/guide-dialog.tsx b/src/components/clinical-dashboard/guide-dialog.tsx index cd9dd51e24..265e713e36 100644 --- a/src/components/clinical-dashboard/guide-dialog.tsx +++ b/src/components/clinical-dashboard/guide-dialog.tsx @@ -143,6 +143,30 @@ function GuideSearch({ ); } +/** + * The tour action is a dock ADDON on phones, so it takes the shared addon-pill + * treatment the other two dock addons use — `Patient details` + * (`patient-details-fab__button`) and Compare's quiet state + * (`differentials-mobile-compare-fab__button--empty`): a rounded pill on + * `--border-strong` over a translucent `--surface`, never a filled slab. + * + * A filled control there defeats the dock. The band behind it is deliberately + * transparent so page content reads through the scrim; dropping an opaque + * primary button on top puts back a smaller version of the exact cover this + * footer was converted to a dock to remove. + * + * From `sm` the footer is a real Sheet band again, the addon framing no longer + * applies, and the primary treatment is the correct one for the guide's main + * call to action — so every override here is `max-sm:`. + */ +const guideTourAction = cn( + primaryControl, + "max-sm:rounded-full max-sm:border max-sm:border-[color:var(--border-strong)]", + "max-sm:bg-[color-mix(in_srgb,var(--surface)_92%,transparent)] max-sm:text-[color:var(--text-heading)]", + "max-sm:font-extrabold max-sm:shadow-[var(--e3)]", + "max-sm:hover:bg-[color-mix(in_srgb,var(--surface-subtle)_92%,transparent)] max-sm:hover:shadow-[var(--e3)]", +); + function GuideTopNavigation({ view, onNavigate }: { view: GuideView; onNavigate: (view: GuideView) => void }) { const items: ReadonlyArray<{ view: GuideView; label: string; icon: LucideIcon }> = [ { view: "home", label: "Guide home", icon: BookOpen }, @@ -789,93 +813,100 @@ function GuideDialogSession({ onClose }: { onClose: () => void }) { ? "Review guided tour" : "Resume guided tour"; const footer = ( -
-
- {view === "tour" && !tourComplete ? ( - <> - - - - - ) : view === "tour" && tourComplete ? ( - - ) : ( - <> - - {view === "topic" || view === "topics" ? ( + <> + {/* Same localized glass the shared phone dock paints: the footer band itself + stays transparent and this scrim tints only around the pill, tapering to + zero at the physical edge. Without it the Sheet's opaque footer surface + reads as a slab covering the content behind the composer. */} + + ); const hasSearch = query.trim().length > 0; @@ -909,7 +940,17 @@ function GuideDialogSession({ onClose }: { onClose: () => void }) { mobilePlacement="fullscreen" footer={footer} footerClassName={cn( - "absolute inset-x-0 bottom-0 z-30 border-t border-[color:var(--border)] bg-[color:var(--surface-raised)] pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))] pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 shadow-[var(--e4)] transition-[transform,opacity] duration-[var(--duration-moderate)] motion-reduce:transition-none sm:static sm:p-4 sm:shadow-none", + // Phones use the SHARED edge-to-edge dock chrome, not a Sheet footer band: + // `.answer-footer-search-dock.answer-footer-search-edge` (globals.css) owns + // the flush left/right/bottom geometry, the safe-area padding and the + // transparent background, exactly as every other phone composer does. The + // border/surface/elevation below are therefore sm+ only — on phones they + // painted an opaque slab across the content behind the composer. The dock + // stays on the DEFAULT scrim height, not `document-mobile-search-compact`: + // the tour action row sits above the pill here, the same shape the + // differentials/patient-details dock addons take. + "answer-footer-search-dock answer-footer-search-edge", + "absolute inset-x-0 bottom-0 z-30 border-t-0 bg-transparent p-0 shadow-none transition-[transform,opacity] duration-[var(--duration-moderate)] motion-reduce:transition-none sm:static sm:border-t sm:border-[color:var(--border)] sm:bg-[color:var(--surface-raised)] sm:p-4", chromeHidden && "pointer-events-none translate-y-full opacity-0 sm:pointer-events-auto sm:translate-y-0 sm:opacity-100", )} diff --git a/tests/guide-centre-chrome.spec.ts b/tests/guide-centre-chrome.spec.ts index 4945c2d95f..7bf04995aa 100644 --- a/tests/guide-centre-chrome.spec.ts +++ b/tests/guide-centre-chrome.spec.ts @@ -114,4 +114,85 @@ test.describe("Clinical KB Guide Centre chrome", () => { await expect(header).toHaveAttribute("aria-hidden", "false"); await expect(header).not.toHaveAttribute("inert"); }); + + /** + * RENDERED EFFECT, not class presence. + * + * `tests/guide-centre-design-contract.dom.test.tsx` asserts the footer carries + * the dock classes, which is the *cause*. jsdom cannot evaluate a media query + * or a cascade layer, so it would pass just as happily with the styles inert — + * the failure mode `tests/helpers/style-contracts.ts` was written about. + * + * Two things here are only provable in a browser: + * + * 1. The band really paints as glass. `Sheet` always wraps its footer slot in + * `border-t border-[color:var(--border)] p-3`, so a transparent, borderless, + * flush-to-the-edge band means the unlayered dock rules actually beat those + * utilities at phone width. + * 2. The tour action really renders as the addon pill. Its overrides are + * `max-sm:` variants layered over `primaryControl`'s own `bg-`/`text-` + * utilities; tailwind-merge keeps BOTH (different variant keys), so which + * one wins is decided by generated stylesheet order. Nothing but a real + * browser at a real width can prove the filled slab did not come back. + */ + test("the phone footer paints as a flush glass dock, not a Sheet footer band", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 820 }); + await mockGuideShell(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("#main-content").first()).toBeVisible({ timeout: 15_000 }); + + const dialog = await openGuide(page); + const band = dialog.locator(".answer-footer-search-dock"); + await expect(band).toBeVisible(); + + const painted = await band.evaluate((element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + const scrim = element.querySelector(".answer-footer-search-backdrop"); + const scrimStyle = scrim ? window.getComputedStyle(scrim) : null; + return { + background: style.backgroundColor, + borderTopWidth: style.borderTopWidth, + boxShadow: style.boxShadow, + left: Math.round(rect.left), + right: Math.round(window.innerWidth - rect.right), + bottom: Math.round(window.innerHeight - rect.bottom), + scrimDisplay: scrimStyle ? scrimStyle.display : null, + scrimHeight: scrimStyle ? Math.round(Number.parseFloat(scrimStyle.height)) : 0, + }; + }); + + // Glass, not a band: fully transparent, no rule, no elevation. + expect(painted.background).toMatch(/rgba\(0, 0, 0, 0\)|transparent/); + expect(painted.borderTopWidth).toBe("0px"); + expect(painted.boxShadow === "none" || /rgba\(0, 0, 0, 0\)/.test(painted.boxShadow)).toBe(true); + + // Edge to edge, flush to the physical bottom — never a floating inset. + expect(painted.left).toBe(0); + expect(painted.right).toBe(0); + expect(painted.bottom).toBe(0); + + // The scrim is what tints around the pill; without it the band is bare. + expect(painted.scrimDisplay).toBe("block"); + expect(painted.scrimHeight).toBeGreaterThan(0); + + const action = dialog.locator("[data-guide-tour-action-row] button").last(); + const addon = await action.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + background: style.backgroundColor, + borderTopWidth: style.borderTopWidth, + borderRadius: Number.parseFloat(style.borderTopLeftRadius), + minHeight: Number.parseFloat(style.minHeight), + }; + }); + + // Addon pill: outlined, pill-radius, and translucent rather than a filled + // slab — `color-mix(in srgb, var(--surface) 92%, transparent)` resolves to a + // colour carrying alpha, which a filled `--command` background never does. + expect(addon.borderTopWidth).toBe("1px"); + expect(addon.borderRadius).toBeGreaterThan(100); + expect(addon.minHeight).toBeGreaterThanOrEqual(48); + expect(addon.background).toMatch(/\/\s*0?\.9|rgba\([^)]+,\s*0?\.9/); + }); }); diff --git a/tests/guide-centre-design-contract.dom.test.tsx b/tests/guide-centre-design-contract.dom.test.tsx index 09258d48c3..29c2ff149e 100644 --- a/tests/guide-centre-design-contract.dom.test.tsx +++ b/tests/guide-centre-design-contract.dom.test.tsx @@ -21,7 +21,65 @@ it("keeps Guide chrome on approved elevation and non-layout transitions", () => expect(footer).not.toBeNull(); expect(header).toHaveClass("transition-[border-color,opacity]"); expect(header).not.toHaveClass("transition-[max-height,padding,border-color,opacity]"); - expect(footer).toHaveClass("shadow-[var(--e4)]"); expect(footer).not.toHaveClass("shadow-[var(--shadow-elevated)]"); expect(footer).toHaveClass("transition-[transform,opacity]"); }); + +/** + * The phone footer is the SHARED edge-to-edge composer dock, not a Sheet footer + * band. Its own surface/border/elevation are sm+ only: on phones they painted an + * opaque `--surface-raised` slab across the content behind the search pill, which + * is exactly the "big cover" every other phone composer avoids by staying + * transparent behind a localized `.answer-footer-search-backdrop` scrim. + */ +it("gives the Guide footer the shared phone composer dock chrome", () => { + render(); + + const dialog = screen.getByRole("dialog", { name: "Clinical KB guide" }); + const footer = dialog.querySelector("[data-guide-mobile-footer]")?.parentElement; + + expect(footer).not.toBeNull(); + expect(footer).toHaveClass("answer-footer-search-dock"); + expect(footer).toHaveClass("answer-footer-search-edge"); + + // Phone band chrome is off; the sm+ Sheet footer keeps it. + expect(footer).toHaveClass("border-t-0", "bg-transparent", "shadow-none", "p-0"); + expect(footer).toHaveClass("sm:border-t", "sm:bg-[color:var(--surface-raised)]", "sm:p-4"); + expect(footer).not.toHaveClass("border-t", "bg-[color:var(--surface-raised)]"); + + // The scrim the dock geometry tints with, hidden once the footer is a real band. + const backdrop = footer?.querySelector(".answer-footer-search-backdrop"); + expect(backdrop).not.toBeNull(); + expect(backdrop).toHaveClass("sm:hidden"); +}); + +/** + * Inside the dock the tour action is an ADDON, and every dock addon in this repo + * is an outlined translucent pill — `Patient details` + * (`patient-details-fab__button`) and Compare's quiet state. A filled primary + * control there puts back a smaller version of the opaque cover the dock + * conversion removed, because the band behind it is deliberately transparent. + * From `sm` the footer is a real band and the primary treatment is correct. + */ +it("renders the Guide tour action as a dock addon pill on phones only", () => { + render(); + + const dialog = screen.getByRole("dialog", { name: "Clinical KB guide" }); + const row = dialog.querySelector("[data-guide-tour-action-row]"); + expect(row).not.toBeNull(); + + const action = row?.querySelector("button:last-of-type"); + expect(action).not.toBeNull(); + + // Addon framing, phone-scoped. + expect(action).toHaveClass( + "max-sm:rounded-full", + "max-sm:border", + "max-sm:border-[color:var(--border-strong)]", + "max-sm:bg-[color-mix(in_srgb,var(--surface)_92%,transparent)]", + "max-sm:shadow-[var(--e3)]", + ); + + // The sm+ primary treatment is still the base, not replaced. + expect(action).toHaveClass("bg-[color:var(--command)]", "text-[color:var(--command-contrast)]"); +});