Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e1ddf3d
Header standard: tabs return on scroll up everywhere, one Back in the…
claude Oct 13, 2026
cc96356
Header standard: record the rule in the chrome docs and move tests to…
claude Oct 13, 2026
730fa9d
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 13, 2026
686e9e6
Speed up the header's return on the two longest Clinical lists
claude Oct 13, 2026
f460c38
Clear the pinned tab rail when jumping to a section on phones
claude Oct 13, 2026
184e8a7
Keep the top bar on screen while its Back control has focus
claude Oct 13, 2026
5e4b9ab
Point the Therapy sheet accessibility test at the builder picker
claude Oct 13, 2026
e3825b1
Move the top bar's Back focus tracking into its own hook
claude Oct 13, 2026
c27ffd8
Name the Therapy sheet builder picker for its accessibility test
claude Oct 13, 2026
bcc0ec6
Hide Psychiatry's box offline, drop its tablet dock reserve, excuse W…
claude Oct 13, 2026
aaa7b70
Keep Work pages under the size budget, fix Back tests and Psychiatry …
claude Oct 13, 2026
2f7b76a
Header follow-ups: keep record actions reachable and section jumps cl…
claude Oct 13, 2026
53dd39a
Merge remote-tracking branch 'origin/main' into header-followups
claude Oct 13, 2026
17b1406
Merge branch 'main' into claude/project-thread-pptby2
BigSimmo Oct 13, 2026
928d38e
Merge remote-tracking branch 'origin/claude/project-thread-pptby2' in…
claude Oct 13, 2026
7ad966c
Count the top bar once on section jumps under a pinned tab row
claude Oct 13, 2026
4efd74d
Merge main into the Header change, keeping the Ask box on Psychiatry
claude Oct 13, 2026
b5f6c2d
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 13, 2026
e26be88
Psychiatry: show the offline note on Saved too, merge main
claude Oct 13, 2026
2df4d43
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 13, 2026
90f6a4e
Docs: record the owner's keep-as-built decision for pinned record hea…
claude Oct 13, 2026
2eb4c7f
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 13, 2026
d20f4a1
docs: drop removed showBackLabel guidance; Back lives in the top bar
claude Oct 13, 2026
6bab4bb
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 13, 2026
9fbdad4
docs: stop counting the breadcrumb row's optional props
claude Oct 13, 2026
457c3f4
Merge branch 'main' into claude/project-thread-pptby2
BigSimmo Oct 13, 2026
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
4 changes: 2 additions & 2 deletions docs/agents/native-startup/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- END:search-chrome-behaviour -->
26 changes: 21 additions & 5 deletions docs/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- **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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mockups/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>`, 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/<slug>`, 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.

Expand Down
46 changes: 46 additions & 0 deletions src/app/mode-band.css
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved

@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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved

: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;
}
55 changes: 48 additions & 7 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ import {
isAlwaysStandaloneShellPath,
isDashboardOwnedModeHomePath,
isDictionaryCataloguePath,
isDockedModeHomePath,
isStandaloneModeHomePath,
shouldRenderClinicalDashboard,
shouldRenderDashboardSearch,
Expand Down Expand Up @@ -305,6 +306,19 @@ function GlobalSearchShellDashboardGate(props: GlobalSearchShellProps) {
return <GlobalStandaloneSearchShellClient {...props} />;
}

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;
}
Expand Down Expand Up @@ -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);
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
const isDictionaryCatalogue = isDictionaryCataloguePath(pathname);
const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations/");
const shouldShowDesktopSidebar = !hideDesktopSidebar;
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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,
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
run: Boolean(trimmedQuery),
// Running a search must not carry focus into the result dock — that pin
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) ? (
<DesktopComposerPortalSlot
id={desktopPageComposerSlotId}
data-testid="desktop-page-search-composer-slot"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
type TextScopeFilterKey,
} from "@/components/clinical-dashboard/master-search-scope-helpers";
import { restoreFocusUnlessMoved, useDismissableLayer } from "@/components/use-dismissable-layer";
import { usePortalSlotFocus } from "@/components/clinical-dashboard/use-portal-slot-focus";
import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { useEventCallback } from "@/components/clinical-dashboard/use-event-callback";
import { useLastAppMode } from "@/components/clinical-dashboard/use-last-app-mode";
Expand Down Expand Up @@ -558,6 +559,9 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) {
};
}, [headerCollapseOwnsPhoneAddonFocus, hideOnScrollEnabled, phoneHeaderCollapseAddonHost]);

const leadingSlotRef = useRef<HTMLDivElement>(null);
usePortalSlotFocus(leadingSlotRef, Boolean(hideOnScroll), setHeaderChromeFocused);
Comment thread
codeant-ai[bot] marked this conversation as resolved.
Resolved

useEffect(() => {
onBottomComposerHiddenChange?.(bottomComposerHidden);
}, [bottomComposerHidden, onBottomComposerHiddenChange]);
Expand Down Expand Up @@ -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. */}
<div id="universal-header-leading" className="contents" />
<div ref={leadingSlotRef} id="universal-header-leading" className="contents" />
<button
type="button"
onClick={onOpenMobileSidebar}
Expand Down
41 changes: 41 additions & 0 deletions src/components/clinical-dashboard/use-portal-slot-focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { useEffect, type RefObject } from "react";

/**
* Pages portal their Back control into the top bar's leading slot. React routes
* focus events from a portal through the page's tree, so the header's own focus
* capture never sees them. Listening on the real DOM slot lets a focused Back
* keep the bar on screen like any other header control.
*/
export function usePortalSlotFocus(
slotRef: RefObject<HTMLElement | null>,
enabled: boolean,
setFocused: (focused: boolean) => void,
) {
useEffect(() => {
const slot = slotRef.current;
if (!slot || !enabled) return undefined;
const slotHoldsFocus = () => document.activeElement instanceof Node && slot.contains(document.activeElement);
const handleFocusIn = () => setFocused(true);
// Back may already hold focus when this starts (focus restored before the
// header enabled hiding); no focusin would follow, so read it now.
if (slotHoldsFocus()) queueMicrotask(() => setFocused(true));
const handleFocusOut = (event: FocusEvent) => {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !slot.contains(nextTarget)) setFocused(false);
};
// A focused Back that navigates away unmounts without a focusout.
const observer = new MutationObserver(() => {
if (!slotHoldsFocus()) setFocused(false);
});
observer.observe(slot, { childList: true, subtree: true });
slot.addEventListener("focusin", handleFocusIn);
slot.addEventListener("focusout", handleFocusOut);
return () => {
slot.removeEventListener("focusin", handleFocusIn);
slot.removeEventListener("focusout", handleFocusOut);
observer.disconnect();
};
}, [slotRef, enabled, setFocused]);
}
Loading
Loading