diff --git a/docs/agents/native-startup/search-chrome-behaviour.md b/docs/agents/native-startup/search-chrome-behaviour.md index a8c0c8a9d7..b5b0342729 100644 --- a/docs/agents/native-startup/search-chrome-behaviour.md +++ b/docs/agents/native-startup/search-chrome-behaviour.md @@ -8,8 +8,8 @@ The shared search chrome must adapt by page ownership, not by ad-hoc padding or - **Phone edge-to-edge contract.** Fixed phone composers are flush to the viewport bottom and paint their own safe-area/home-indicator region while visible. They must not use a non-zero `bottom` gap in edge-to-edge dock mode. - **Hidden means zero reserve.** When phone search/header/footer chrome scroll-hides, the content-facing reserve is `0rem`; do not restore `0.75rem`, `env(safe-area-inset-bottom)`, or `var(--safe-area-bottom)` as hidden padding. Visible composer chrome may still consume safe-area inset. - **Header/footer symmetry.** Top header and bottom composer hide/reveal from the same scroll signal where they share a scroll container. If one is hidden, page content behind that edge must be fully visible rather than covered by an opaque white/surface band. -- **Page adaptation.** Standalone mode homes keep the composer in-flow in the hero on phones; submitted/search-result views use the compact bottom dock; answer mode may use overlaid glass header behaviour with matching top reserve; document detail/source routes let `DocumentViewer` own its composer. -- **Default in-page navigation.** When adding or suggesting in-page navigation on any mode page, use the DocumentViewer header as the template: back control, title + active-section subtitle + chevron sheet, ellipsis actions, weighted segment track, and `PhoneHeaderCollapsePortal` so the header attaches under the universal phone header and hides/reveals with that single collapse owner. Do not invent a second sticky/fixed phone nav header or a separate scroll-hide hook. Full contract: `docs/search-chrome-behaviour.md` (“Default in-page navigation template”). Therapy `ModeNav` remains a different multi-route pattern. +- **Page adaptation.** Standalone mode homes keep the composer in-flow in the hero on phones (Psychiatry's home is the exception: `dockedModeHomePaths` gives it the compact bottom dock and a page slot under its band); submitted/search-result views use the compact bottom dock; answer mode may use overlaid glass header behaviour with matching top reserve; document detail/source routes let `DocumentViewer` own its composer. +- **Default in-page navigation.** When adding or suggesting in-page navigation on any mode page, use the DocumentViewer header as the template: back control, title + active-section subtitle + chevron sheet, ellipsis actions, weighted segment track, and `PhoneHeaderCollapsePortal` so the header attaches under the universal phone header and hides/reveals with that single collapse owner. Header standard (13 Oct 2026): Back sits in the top bar, a tab rail pins under the bar through the Work band mechanism (`useStickyWorkTabs`) while the title scrolls away, and a row with no section navigation scrolls away. Do not invent a second sticky/fixed phone nav header or a separate scroll-hide hook. Full contract: `docs/search-chrome-behaviour.md` (“Default in-page navigation template”). Therapy `ModeNav` remains a different multi-route pattern. - **Guards.** Update the reserve helper, CSS tokens, Playwright phone-scroll coverage, and static contract tests together. Do not silence the existing reserve/overlay tests; add a narrower guard for any new page-specific exception. Run `npm run verify:phone-chrome`; its smart selector must keep focused owner/journey proof before any recommended full `verify:ui` escalation. diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 891ee4e011..1a9a6dd724 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -123,6 +123,24 @@ the rendered heights, or pass explicit weights when the sections are discrete ta with no on-screen height to measure. Ownership and reserves for that chrome are the “Document section navigation” row above. +**Header standard (owner decision, 13 Oct 2026).** Every header hides on a scroll down and +comes back on a scroll up; what comes back is the top bar plus the page's tab row, never a +big title. For `InPageNavHeader` that means: + +- **Back is in the top bar.** `back` is drawn by `WorkPageBack` in the top bar's round left + button (the 11 Oct "one Back, in the top bar" rule), never in the page's own row. +- **A tab rail pins in the page.** With `rail`, the header stays in page flow and + `useStickyWorkTabs` (the Work band mechanism, not a new scroll-hide owner) pins the rail + under the bar; the title row scrolls away. Once the rail is stuck, the header's actions + (Save, the ⋯ menu) move to the end of the pinned rail. Under a mode band the band's tabs + are the pinned row, so the record's header, rail and actions included, scrolls away (owner + decision, 13 Oct 2026, keep as built). +- **Section navigation without a rail rides the collapse row** through + `PhoneHeaderCollapsePortal`, as before: the title disclosure is that page's navigation. +- **A row with no section navigation scrolls away** in page flow, like a band title. + +Psychiatry, Medicines and First Nations bands (`ModeBandHeader`) pin their tabs the same way. + Sections that the reader can jump to carry `inPageAnchor` (`src/components/in-page-nav/in-page-nav-classes.ts`), which is `scroll-mt-[var(--inpage-anchor-offset,6rem)]`. The property is published from the live chrome @@ -277,12 +295,10 @@ purpose: `DocumentViewer` (decided above) and the differentials presentations wo one item and the track would render one full-width segment. Omit `sections` and `InPageNavHeader` drops both and renders the breadcrumb shape instead — same row grammar, same single collapse owner, none of the section machinery. `usePageSectionWeights` observes -nothing for an empty list, so those pages pay no measurement cost. Three optional props -shape that row: +nothing for an empty list, so those pages pay no measurement cost. Back is never in this +row: `WorkPageBack` renders it as the top bar's round left button, with `back.label` as its +accessible name. The optional props below shape the row: -- `showBackLabel={false}` keeps the arrow alone at every width when the row also carries an - action or a mode, so the title owns the space. `back.label` is still the accessible name - and becomes the desktop tooltip. - `primaryAction` promotes exactly **one** page action. It is **not** the filled `--command` slab, because a control pinned to every scroll position should not be the page's heaviest. Its label is `sr-only` below `sm` so the accessible name does not change with the diff --git a/mockups/README.md b/mockups/README.md index 49d9a86987..d53de5f210 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -607,7 +607,7 @@ Three sticky header directions for record pages that use `InformationPageBreadcr | 02 Action rail | One promoted primary action pill | Factsheets, services, medications | | 03 Crumb rail + mode | Segmented view mode in the track slot | Factsheet reading level, medication age | -**Outcome: direction 02 shipped**, as the breadcrumb shape of the existing `InPageNavHeader` rather than a new component — omitting `sections` drops the disclosure and the track, and `primaryAction` / `mode` / `showBackLabel` shape the row. Adopted first on `/factsheets/`, where the reading level rides the `mode` slot. Contract: `docs/search-chrome-behaviour.md` ("The breadcrumb shape"). +**Outcome: direction 02 shipped**, as the breadcrumb shape of the existing `InPageNavHeader` rather than a new component — omitting `sections` drops the disclosure and the track, and `primaryAction` / `mode` shape the row (Back now sits in the top bar's round left button). Adopted first on `/factsheets/`, where the reading level rides the `mode` slot. Contract: `docs/search-chrome-behaviour.md` ("The breadcrumb shape"). **The runnable `/mockups/breadcrumb-header` route was removed once 02 shipped.** It was not deleted for tidiness: at the time, `check:bundle-budget` totalled every built chunk, mockups included, and `main` sat at roughly +9.4% against a 10% tolerance, so the study's two scratch chunks (~9.8 KiB gzip) alone pushed the repo to +10.1% and failed `Build` — the same failure PR #1580 hit, at the same number. (Since PR #1779 `bundle-budget.json` keeps mockups in their own `mockups` scratch bucket with a 25% tolerance, separate from the `production` bucket, so a scratch route no longer competes with production headroom.) A design-scratch route that 404s in production is the wrong thing to spend the last of that headroom on. The table above is the durable record; recover the route from history if the alternatives need re-reading. diff --git a/src/app/mode-band.css b/src/app/mode-band.css index 9bcef67ed2..1c9b2646d3 100644 --- a/src/app/mode-band.css +++ b/src/app/mode-band.css @@ -469,3 +469,49 @@ html:not([data-mode-band]) .universal-header[data-clinical-band]:not(:has(~ #phone-header-collapse-addon-slot .mode-nav-rail)) { box-shadow: inset 0 -1px 0 var(--clinical-band-line); } + +/* Pinned tabs on Clinical pages (header standard, 13 Oct 2026). Psychiatry, + Medicines and First Nations bands, and record pages with a tab rail, behave + as a Work band does: once the title has scrolled under the top bar, only the + tab row stays, pinned just below the bar, and it slides away and back with + the bar. use-sticky-work-tabs.ts measures the offsets; the rules mirror + `.work-band` in work-mode.css, which also sets `--work-chrome-h` for Work + pages. Every other page takes it from here. */ +html { + --work-chrome-h: calc(var(--safe-area-top, 0px) + var(--work-bar-h, var(--shell-header-h))); +} + +@media (max-width: 639px) { + html { + --work-chrome-h: var(--phone-overlay-chrome-h); + } +} + +/* A jump to a section, a find or a focused field lands below the pinned row, + not under it, as on Work pages (work-mode.css). */ +html:has(:is(.mode-band, .inpage-nav-header)[data-pinned]), +html:has(:is(.mode-band, .inpage-nav-header)[data-pinned]) #main-content { + scroll-padding-top: calc(var(--work-chrome-h) + var(--work-tabs-h, 0px)); +} + +:is(.mode-band, .inpage-nav-header)[data-pinned] { + position: sticky; + top: calc(var(--work-chrome-h) - var(--work-band-tabs-top, 0px)); + z-index: calc(var(--z-raised, 10) + 1); +} + +:is(.mode-band, .inpage-nav-header)[data-stuck] { + clip-path: inset(var(--work-band-tabs-top, 0px) 0 0 0); +} + +.mode-band[data-stuck] .mode-band__mark { + opacity: 0; +} + +:is(.mode-band, .inpage-nav-header)[data-pinned][data-bar-hidden]:not(:focus-within) { + top: calc(-1 * (var(--work-band-tabs-top, 0px) + var(--work-tabs-h, 0px))); +} + +:is(.mode-band, .inpage-nav-header)[data-stuck][data-bar-hidden]:not(:focus-within) { + pointer-events: none; +} diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 67ef177caa..17f4bc7a9c 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -135,6 +135,7 @@ import { isAlwaysStandaloneShellPath, isDashboardOwnedModeHomePath, isDictionaryCataloguePath, + isDockedModeHomePath, isStandaloneModeHomePath, shouldRenderClinicalDashboard, shouldRenderDashboardSearch, @@ -305,6 +306,19 @@ function GlobalSearchShellDashboardGate(props: GlobalSearchShellProps) { return ; } +function subscribeOnlineStatus(listener: () => void): () => void { + window.addEventListener("online", listener); + window.addEventListener("offline", listener); + return () => { + window.removeEventListener("online", listener); + window.removeEventListener("offline", listener); + }; +} + +function readBrowserOffline(): boolean { + return navigator.onLine === false; +} + function subscribeNoop() { return () => undefined; } @@ -513,7 +527,13 @@ function GlobalStandaloneSearchShellBody({ // Pathname-only: do not require searchMode === route. changeMode used to set // searchMode before router.push landed, which made isStandaloneModeHome false // for one frame (dock reserve + 200ms padding transition = choppy resize). - const isStandaloneModeHome = !hasSubmittedModeSearch && !rendersDashboardSearch && isStandaloneModeHomePath(pathname); + // A docked mode home (Psychiatry) takes the results-page search box: the + // compact bottom dock on phones and the page slot from tablet up. + const isStandaloneModeHome = + !hasSubmittedModeSearch && + !rendersDashboardSearch && + isStandaloneModeHomePath(pathname) && + !isDockedModeHomePath(pathname); const isDictionaryCatalogue = isDictionaryCataloguePath(pathname); const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations/"); const shouldShowDesktopSidebar = !hideDesktopSidebar; @@ -545,7 +565,9 @@ function GlobalStandaloneSearchShellBody({ }; const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow || workSideNav ? true : sidebarCollapsed; const effectiveSidebarWidth = shouldShowDesktopSidebar ? (effectiveSidebarCollapsed ? "5.25rem" : "20rem") : "0px"; - const isInfoPage = isInformationPage(pathname); + // Psychiatry stays an information page for its navigation, but its home now + // takes the search box (see `dockedModeHomePaths`). + const isInfoPage = isInformationPage(pathname) && !isDockedModeHomePath(pathname); // Information pages are read surfaces: the record has already been found, so a // composer there is chrome you cannot use without leaving the page. No mode is // an exception and no breakpoint is — the phone dock, the tablet/desktop page @@ -555,8 +577,15 @@ function GlobalStandaloneSearchShellBody({ // docks (`/services/search`, `/forms/search`) keep their composer because // `isSlugDetail` excludes the reserved `search` suffix, not because of any // route named here. + // Offline, Psychiatry's box could only fail after a tap; its page says search + // needs a connection, so the box steps aside until the browser is back online. + const browserOffline = useSyncExternalStore(subscribeOnlineStatus, readBrowserOffline, () => false); const shouldShowSearchComposer = - searchComposerVisible && pathname !== "/tools" && !isDifferentialPresentationWorkflow && !isInfoPage; + searchComposerVisible && + pathname !== "/tools" && + !isDifferentialPresentationWorkflow && + !isInfoPage && + !(browserOffline && isDockedModeHomePath(pathname)); // `/tools` owns its catalogue controls rather than a shared composer. Keep // the sidebar's cross-guide search usable by returning to Answer first. const openSidebarSearch = pathname === "/tools" ? () => startNewAnswerChat() : () => focusComposerInput(inputRef); @@ -567,7 +596,9 @@ function GlobalStandaloneSearchShellBody({ // Dictionary catalogue keeps the usual compact phone dock; sm+ still // portals into the page-owned slot it renders under its mode nav, so it needs // no floating-composer clearance either. - const reservesFloatingComposer = shouldShowSearchComposer && !isStandaloneModeHome && !isDictionaryCatalogue; + // Psychiatry, a docked home, likewise portals into its own page slot from tablet up. + const reservesFloatingComposer = + shouldShowSearchComposer && !isStandaloneModeHome && !isDictionaryCatalogue && !isDockedModeHomePath(pathname); // Most standalone mode homes keep the in-flow hero pill at every width. Tools // deliberately has no shared composer. Document viewer routes own their own // floating composer, so @@ -769,7 +800,9 @@ function GlobalStandaloneSearchShellBody({ function submitSearch(queryOverride?: string) { const trimmedQuery = (queryOverride ?? query).trim(); - navigateToMode(searchMode, { + // Psychiatry has no results page, so its box asks the question in Answer, + // as its old in-page box did. + navigateToMode(isDockedModeHomePath(pathname) && trimmedQuery ? "answer" : searchMode, { query: trimmedQuery || undefined, run: Boolean(trimmedQuery), // Running a search must not carry focus into the result dock — that pin @@ -845,7 +878,11 @@ function GlobalStandaloneSearchShellBody({ function pickRecentQuery(recentQuery: string) { setMobileMenuOpen(false); - navigateToMode(searchMode, { query: recentQuery, focus: false, run: true }); + navigateToMode(isDockedModeHomePath(pathname) ? "answer" : searchMode, { + query: recentQuery, + focus: false, + run: true, + }); } function crossModeSearch(mode: AppModeId, crossQuery: string) { @@ -1203,7 +1240,11 @@ function GlobalStandaloneSearchShellBody({ {/* The dictionary catalogue renders this same slot id itself, under its own mode nav and above the Filter band, so the shell must not emit a second element carrying it — one id, one portal host. */} - {shouldShowSearchComposer && !isStandaloneModeHome && !isDictionaryCatalogue ? ( + {/* Psychiatry's home renders it too, under its band. */} + {shouldShowSearchComposer && + !isStandaloneModeHome && + !isDictionaryCatalogue && + !isDockedModeHomePath(pathname) ? ( (null); + usePortalSlotFocus(leadingSlotRef, Boolean(hideOnScroll), setHeaderChromeFocused); + useEffect(() => { onBottomComposerHiddenChange?.(bottomComposerHidden); }, [bottomComposerHidden, onBottomComposerHiddenChange]); @@ -2607,7 +2611,7 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { button here (`WorkFrameBack`); CSS then stands the menu button down, so the round left control is one or the other. With the two-pane side menu both show, the menu first. */} -
+
+ ) : null} + {actions ? ( + + ) : null} + + ) : null; return ( <> - + {back ? : null} +
- {back ? ( - - - {showBackLabel ? {back.label} : null} - - ) : null} {rail && !titleHidden ? ( // With a rail, every section is already named in the row below, so // from `sm` — where the whole rail fits — the disclosure would open @@ -413,72 +494,7 @@ export function InPageNavHeader(props: InPageNavHeaderProps) { {title} )} - {primaryAction || actions ? ( - // One joined group, not two free-standing controls. A bordered - // promoted action beside a borderless ellipsis reads as two - // unrelated things competing at the end of the row; a single - // border with a hairline between the members reads as one control - // with two actions. `sm:order-2` keeps the phone DOM order - // (verbs before mode) as the keyboard order while desktop still - // paints mode between the title and the verbs. - - {primaryAction ? ( - // Not the filled `--command` slab: a control pinned to every - // scroll position should not be the page's heaviest. The label - // is `sr-only` below `sm` so the accessible name never changes - // with the breakpoint. - - ) : null} - {actions ? ( - - ) : null} - - ) : null} + {railStuck ? null : actionGroup} {mode ? ( // Inline from `sm` only. Below that it renders inside the actions // sheet instead — see the `mode` prop docs. `order-1` pulls it @@ -502,24 +518,29 @@ export function InPageNavHeader(props: InPageNavHeaderProps) {
) : null} {rail ? ( - onSelectSection?.(id)} - onOpenSectionSheet={openSectionSheet} - sectionSheetOpen={sectionSheetOpen} - label={rail.label} - density={rail.density} - countedLabels={rail.countedLabels} - modeIdentity={rail.modeIdentity} - flush={!rowHasContent} - testIdPrefix={testIdPrefix} - /> +
+
+ onSelectSection?.(id)} + onOpenSectionSheet={openSectionSheet} + sectionSheetOpen={sectionSheetOpen} + label={rail.label} + density={rail.density} + countedLabels={rail.countedLabels} + modeIdentity={rail.modeIdentity} + flush={!rowHasContent} + testIdPrefix={testIdPrefix} + /> +
+ {railStuck ? actionGroup : null} +
) : documentSections.length > 0 ? ( ) : null}
- + {/* Both sheets are siblings of the portal, never children of it: a sheet inside the collapse row would be carried away with the header when the shared chrome scroll-hides. */} @@ -608,3 +629,8 @@ export function InPageNavHeader(props: InPageNavHeaderProps) { ); } + +/** Keeps a pinned-rail header in the page, where its rail can stick under the top bar. */ +function InFlowHeader({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/components/mode-band/mode-band.tsx b/src/components/mode-band/mode-band.tsx index 0e5cf58711..bc1a3ade75 100644 --- a/src/components/mode-band/mode-band.tsx +++ b/src/components/mode-band/mode-band.tsx @@ -11,6 +11,7 @@ import { useState, useLayoutEffect, useMemo, + useRef, useSyncExternalStore, Suspense, type CSSProperties, @@ -43,6 +44,7 @@ import { } from "@/lib/work-frame/areas"; import { ExampleDataBanner } from "@/components/example-data/example-data-banner"; import { WorkFrameHeader } from "@/components/work-frame/work-frame-header"; +import { useStickyWorkTabs } from "@/components/work-frame/use-sticky-work-tabs"; import { useWorkFrameAction } from "@/components/work-frame/work-frame-store"; import { WorkHelpHost } from "@/components/work-help/work-help-host"; import { openWorkHelp } from "@/components/work-help/work-help-store"; @@ -576,6 +578,12 @@ function ModeBandHeader({ }, [tabs, modeId, teachingRoles, rosterHasTeam, openShiftsPoster]); usePublishBandSurface(band, modeId); + // The tab row pins under the top bar and slides away and back with it, the + // same mechanism as a Work band (header standard, 13 Oct 2026). + const bandTabsRef = useRef(null); + // The row only renders with two or more tabs; keying the hook on that lets it + // start (or clean up) when the count crosses the line on the same band. + useStickyWorkTabs(tabEntries.length > 1 ? band : null, bandTabsRef); const tabRow = useCurrentTabInView(activeId, tabEntries.map((entry) => entry.id).join(" ")); const Icon = appModeIcons[modeId]; @@ -639,7 +647,12 @@ function ModeBandHeader({ ) : null}
{tabEntries.length > 1 ? ( -