From 8298bfdcb40c207dbac1128e83c07b4aba782e32 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Fri, 17 Jul 2026 13:30:33 +0800
Subject: [PATCH 1/2] fix(ui): refine scroll chrome timing
---
.../master-search-header.tsx | 26 +++++-
.../clinical-dashboard/use-hide-on-scroll.ts | 58 +++++++++++--
tests/use-hide-on-scroll.test.ts | 82 +++++++++++++++++--
3 files changed, 148 insertions(+), 18 deletions(-)
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx
index 40a7be517..6c9102785 100644
--- a/src/components/clinical-dashboard/master-search-header.tsx
+++ b/src/components/clinical-dashboard/master-search-header.tsx
@@ -1243,7 +1243,12 @@ export function MasterSearchHeader({
usesCompactMobileBottomStyle && "document-mobile-search-compact",
showFooterSearchChips && "flex flex-col items-center gap-2.5",
shouldHideBottomOnScroll &&
- "max-sm:transition-transform max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none",
+ cn(
+ "max-sm:transition-transform motion-reduce:transition-none",
+ bottomComposerHidden
+ ? "max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
+ : "max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
+ ),
)}
>
{usesBottomComposerPlacement ?
: null}
@@ -1516,8 +1521,18 @@ export function MasterSearchHeader({
// fixed-position mobile mode menu keeps the viewport as its containing block.
hideStrategy === "overlay" &&
(overlayAllBreakpoints
- ? "transition-transform duration-200 ease-out motion-reduce:transition-none"
- : "max-sm:transition-transform max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none"),
+ ? cn(
+ "transition-transform motion-reduce:transition-none",
+ headerChromeHidden
+ ? "duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
+ : "duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]",
+ )
+ : cn(
+ "max-sm:transition-transform motion-reduce:transition-none",
+ headerChromeHidden
+ ? "max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
+ : "max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
+ )),
hideStrategy === "overlay" &&
headerChromeHidden &&
(overlayAllBreakpoints ? "-translate-y-full" : "max-sm:-translate-y-full"),
@@ -1734,7 +1749,10 @@ export function MasterSearchHeader({
data-scroll-hidden={headerChromeHidden ? "true" : undefined}
data-testid="universal-header-collapse"
className={cn(
- "max-sm:grid max-sm:transition-[grid-template-rows] max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none",
+ "max-sm:grid max-sm:transition-[grid-template-rows] motion-reduce:transition-none",
+ headerChromeHidden
+ ? "max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
+ : "max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
headerChromeHidden ? "max-sm:[grid-template-rows:0fr]" : "max-sm:[grid-template-rows:1fr]",
)}
{...chromeFocusProps}
diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts
index 37a8586ea..750721013 100644
--- a/src/components/clinical-dashboard/use-hide-on-scroll.ts
+++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts
@@ -16,26 +16,58 @@ const topRevealOffset = 8;
// Minimum per-event delta (px) before we treat movement as intentional, to
// avoid jitter from momentum settling and fractional scroll positions.
const minimumDelta = 4;
+// Once the header's activation band has passed, require a little more
+// continuous downward travel before hiding. Reappearing should be easier, but
+// still deliberate enough that trackpad/touch momentum cannot flicker the
+// chrome at a direction change.
+const hideIntentDistance = 24;
+const revealIntentDistance = 12;
+
+type ScrollDirection = "down" | "up" | null;
/** Pure scroll-direction evaluation used by the hook; exported for unit tests. */
-export function computeScrollHideUpdate(params: { offset: number; lastOffset: number; currentlyHidden: boolean }): {
+export function computeScrollHideUpdate(params: {
+ offset: number;
+ lastOffset: number;
+ currentlyHidden: boolean;
+ direction?: ScrollDirection;
+ directionTravel?: number;
+}): {
hidden: boolean;
lastOffset: number;
+ direction: ScrollDirection;
+ directionTravel: number;
} {
- const { offset, lastOffset, currentlyHidden } = params;
+ const { offset, lastOffset, currentlyHidden, direction = null, directionTravel = 0 } = params;
// Ignore iOS rubber-band overscroll at the top.
- if (offset < 0) return { hidden: currentlyHidden, lastOffset };
+ if (offset < 0) return { hidden: currentlyHidden, lastOffset, direction, directionTravel };
const delta = offset - lastOffset;
if (offset <= topRevealOffset) {
- return { hidden: false, lastOffset: offset };
+ return { hidden: false, lastOffset: offset, direction: null, directionTravel: 0 };
}
if (Math.abs(delta) < minimumDelta) {
- return { hidden: currentlyHidden, lastOffset };
+ return { hidden: currentlyHidden, lastOffset, direction, directionTravel };
}
- if (delta > 0) {
- return { hidden: offset > hideActivationOffset, lastOffset: offset };
+
+ const nextDirection: Exclude = delta > 0 ? "down" : "up";
+ const nextDirectionTravel = nextDirection === direction ? directionTravel + Math.abs(delta) : Math.abs(delta);
+ let hidden = currentlyHidden;
+
+ if (!currentlyHidden && nextDirection === "down" && offset > hideActivationOffset) {
+ // Only count travel beyond the activation band. This stops a single flick
+ // from the top hiding the chrome the instant it clears the header height.
+ const travelPastActivation = Math.min(nextDirectionTravel, offset - hideActivationOffset);
+ hidden = travelPastActivation >= hideIntentDistance;
+ } else if (currentlyHidden && nextDirection === "up" && nextDirectionTravel >= revealIntentDistance) {
+ hidden = false;
}
- return { hidden: false, lastOffset: offset };
+
+ return {
+ hidden,
+ lastOffset: offset,
+ direction: nextDirection,
+ directionTravel: nextDirectionTravel,
+ };
}
function subscribeToPhoneMedia(onChange: () => void) {
@@ -67,6 +99,8 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa
const [hidden, setHidden] = useState(false);
const hiddenRef = useRef(false);
const lastOffsetRef = useRef(0);
+ const directionRef = useRef(null);
+ const directionTravelRef = useRef(0);
const active = usePhoneScrollHideActive(disabled, allowAllBreakpoints);
const reportScroll = useCallback(
@@ -79,9 +113,13 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa
offset,
lastOffset,
currentlyHidden: hiddenRef.current,
+ direction: directionRef.current,
+ directionTravel: directionTravelRef.current,
});
lastOffsetRef.current = update.lastOffset;
hiddenRef.current = update.hidden;
+ directionRef.current = update.direction;
+ directionTravelRef.current = update.directionTravel;
setHidden(update.hidden);
},
[active],
@@ -91,6 +129,8 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa
if (active) return undefined;
hiddenRef.current = false;
lastOffsetRef.current = 0;
+ directionRef.current = null;
+ directionTravelRef.current = 0;
const frame = window.requestAnimationFrame(() => setHidden(false));
return () => window.cancelAnimationFrame(frame);
}, [active]);
@@ -104,6 +144,8 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa
useEffect(() => {
hiddenRef.current = false;
lastOffsetRef.current = 0;
+ directionRef.current = null;
+ directionTravelRef.current = 0;
const frame = window.requestAnimationFrame(() => setHidden(false));
return () => window.cancelAnimationFrame(frame);
}, [allowAllBreakpoints]);
diff --git a/tests/use-hide-on-scroll.test.ts b/tests/use-hide-on-scroll.test.ts
index 682e622ec..7b25a9de4 100644
--- a/tests/use-hide-on-scroll.test.ts
+++ b/tests/use-hide-on-scroll.test.ts
@@ -7,17 +7,45 @@ describe("computeScrollHideUpdate", () => {
expect(computeScrollHideUpdate({ offset: 0, lastOffset: 0, currentlyHidden: true })).toEqual({
hidden: false,
lastOffset: 0,
+ direction: null,
+ directionTravel: 0,
});
expect(computeScrollHideUpdate({ offset: 8, lastOffset: 20, currentlyHidden: true })).toEqual({
hidden: false,
lastOffset: 8,
+ direction: null,
+ directionTravel: 0,
});
});
- it("hides after scrolling down past the activation offset", () => {
- expect(computeScrollHideUpdate({ offset: 80, lastOffset: 10, currentlyHidden: false })).toEqual({
+ it("waits for deliberate travel beyond the activation offset before hiding", () => {
+ const beforeThreshold = computeScrollHideUpdate({
+ offset: 92,
+ lastOffset: 60,
+ currentlyHidden: false,
+ direction: "down",
+ directionTravel: 60,
+ });
+ expect(beforeThreshold).toEqual({
+ hidden: false,
+ lastOffset: 92,
+ direction: "down",
+ directionTravel: 92,
+ });
+
+ expect(
+ computeScrollHideUpdate({
+ offset: 100,
+ lastOffset: beforeThreshold.lastOffset,
+ currentlyHidden: beforeThreshold.hidden,
+ direction: beforeThreshold.direction,
+ directionTravel: beforeThreshold.directionTravel,
+ }),
+ ).toEqual({
hidden: true,
- lastOffset: 80,
+ lastOffset: 100,
+ direction: "down",
+ directionTravel: 100,
});
});
@@ -25,13 +53,34 @@ describe("computeScrollHideUpdate", () => {
expect(computeScrollHideUpdate({ offset: 40, lastOffset: 10, currentlyHidden: false })).toEqual({
hidden: false,
lastOffset: 40,
+ direction: "down",
+ directionTravel: 30,
});
});
- it("reveals again on deliberate scroll up", () => {
- expect(computeScrollHideUpdate({ offset: 120, lastOffset: 180, currentlyHidden: true })).toEqual({
+ it("reveals after a short but deliberate upward travel", () => {
+ const firstUpwardMove = computeScrollHideUpdate({
+ offset: 174,
+ lastOffset: 180,
+ currentlyHidden: true,
+ direction: "down",
+ directionTravel: 80,
+ });
+ expect(firstUpwardMove.hidden).toBe(true);
+
+ expect(
+ computeScrollHideUpdate({
+ offset: 166,
+ lastOffset: firstUpwardMove.lastOffset,
+ currentlyHidden: firstUpwardMove.hidden,
+ direction: firstUpwardMove.direction,
+ directionTravel: firstUpwardMove.directionTravel,
+ }),
+ ).toEqual({
hidden: false,
- lastOffset: 120,
+ lastOffset: 166,
+ direction: "up",
+ directionTravel: 14,
});
});
@@ -39,6 +88,8 @@ describe("computeScrollHideUpdate", () => {
expect(computeScrollHideUpdate({ offset: -12, lastOffset: 4, currentlyHidden: true })).toEqual({
hidden: true,
lastOffset: 4,
+ direction: null,
+ directionTravel: 0,
});
});
@@ -46,6 +97,25 @@ describe("computeScrollHideUpdate", () => {
expect(computeScrollHideUpdate({ offset: 82, lastOffset: 80, currentlyHidden: false })).toEqual({
hidden: false,
lastOffset: 80,
+ direction: null,
+ directionTravel: 0,
+ });
+ });
+
+ it("resets intent when the user changes direction", () => {
+ expect(
+ computeScrollHideUpdate({
+ offset: 76,
+ lastOffset: 80,
+ currentlyHidden: false,
+ direction: "down",
+ directionTravel: 80,
+ }),
+ ).toEqual({
+ hidden: false,
+ lastOffset: 76,
+ direction: "up",
+ directionTravel: 4,
});
});
});
From 42461a399162494a1c43fad5207e2a61b5077095 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Fri, 17 Jul 2026 14:51:27 +0800
Subject: [PATCH 2/2] docs: record scroll timing review
---
docs/branch-review-ledger.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md
index 5eeb0f47c..c107206c7 100644
--- a/docs/branch-review-ledger.md
+++ b/docs/branch-review-ledger.md
@@ -560,4 +560,5 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD
| 2026-07-15 | codex/outstanding-work-cleanup | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f + working-tree diff | repo-wide outstanding-work reconciliation and cleanup | No high-confidence P0/P1 remained in the locally executable scope. Fixed the stale architecture index for DSM/specifier routes, pruned redundant Knip configuration, removed the unused `SignedImage` default export, and reconciled maintained docs so completed/superseded plans no longer present as active work. Retained explicitly blocked work: provider-gated operator actions, the live-eval shadow reindex harness, deep-memory section-ownership design, and RAG follow-ups that require live evidence or product/security decisions. Full Knip findings were triaged rather than mass-deleted; unresolved-import scan is clean. | Offline `npm run verify:cheap` passed with 2,417 tests/1 skipped; docs links 806, script refs 264, codebase index 35/35; Knip unresolved scan clean; typecheck clean; focused Vitest 15/15; env-name parity clean; `git diff --check`. Provider-backed Supabase/OpenAI/GitHub/Railway, dependency audit, browser, build, drift, and release gates not run. |
| 2026-07-15 | codex/design-polish-pass | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f + reviewed working diff | full live design, responsive, UX, accessibility, design-system, routing, performance, lint, testing, documentation, and release-readiness review | No P0/P1 reproduced. Fixed the P2 duplicate phone scroll surface in the shared standalone shell and added a regression test; fixed mode-menu Tab dismissal; explicitly hid decorative dynamic icons; restricted press scaling to motion-safe environments; moved sheet backdrops and forced-colour glass/backdrop behavior onto design tokens. External target fidelity remains blocked because no independent Figma/mockup source was supplied. | Live 30-route desktop + 21-route phone sweep; targeted 320/390/639/768/1440/1920 proofs; `npm run verify:cheap` (2417 passed/1 skipped); `npm run verify:ui` (175/175); focused keyboard 1/1; accessibility media/axe 5/5; scoped Prettier, ESLint, TypeScript, type-scale, and icon-scale passed. Provider-backed checks not run. |
| 2026-07-15 | codex/documents-closed-default | 49f63791bced2b1764a11ab723aea94b45b026b6 | documents viewer disclosure defaults and related defect hunt | Fixed the inconsistent default-open document viewer sections by making indexed text, high-yield summary, tables/diagrams, and indexing details a native mutually exclusive closed disclosure group. The section navigation opens its requested disclosure and deep-linked evidence still reveals its target. The hunt also removed the explicitly open nested table-review queue, preserved printable summary content through the browser print lifecycle, and added cold-server readiness guards to the affected viewer tests. No other high-confidence default-open defect remains in the live Documents scope. | `npm run verify:cheap`; TypeScript; focused ESLint/Prettier; clean-worktree mocked Chromium coverage for deep-linked evidence, structured summary, closed/mutually-exclusive disclosures, navigation opening, and print state restore; `git diff --check`. Turbopack could not run through the local external `node_modules` junction, so clean browser verification used Next's supported Webpack dev mode. No Supabase/OpenAI/live-provider checks run. |
+| 2026-07-17 | codex/header-footer-scroll-timing-20260717 | 8298bfdcb40c207dbac1128e83c07b4aba782e32 | header and bottom-composer scroll timing, motion, responsive behavior, and merge readiness | Added deliberate hide/reveal travel thresholds with direction-reset handling, aligned header and composer easing/durations, and preserved reduced-motion and breakpoint behavior. No remaining high-confidence P0-P2 defect was found in the scoped diff or focused live behavior. | Focused Vitest 7/7; targeted Chromium UI 5/5; scoped ESLint; Prettier; full TypeScript; full lint; `git diff --check`. `verify:cheap` reached the aggregate Vitest phase, where two repository graph scans exceeded their 30-second test timeout under local disk contention; isolated assertions passed until the same timeout. No Supabase/OpenAI/live-provider checks run. |
| 2026-07-15 | HEAD / main snapshot (detached review worktree) | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f | comprehensive repository review | Changes requested: two P1 defects (high-risk clinical claim support can accept a different trigger condition on lexical overlap; document-mode URL auto-run loops on navigation and leaves search loading indefinitely), one P2 supply-chain guard gap (the action-pin checker accepts mutable major tags and ignores SHA-pinned major versions), and one P3 orientation-doc gap (DSM and legacy specifier routes are absent from the codebase index). No P0 found. | Node 24/npm 11 and `npm ls --depth=0`; format, runtime, lint, TypeScript, sitemap/brand/icon/type-scale, docs links/scripts, CI/action/Codex guards; coverage 259 files passed/1 skipped and 2,417 tests passed/1 skipped; offline RAG 36 fixtures plus 282 tests; production build/client-secret scan; deployment boot smoke; critical Chromium 8/10 with two reproducible document-search failures; accessibility 5/5; viewport/focus checks through 1920x1080. Provider-backed governance, quality, drift, tenancy, hosted CI, and the cross-browser matrix were blocked or skipped by policy/targeted failures. |