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)]");
+});