Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/design-system/adoption-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1851,6 +1851,7 @@
"tests/progress-mark.dom.test.tsx",
"tests/rag-claim-support.test.ts",
"tests/teaching-assessments-additions.dom.test.tsx",
"tests/ui-phone-scroll-assessments.spec.ts",
"tests/ui-v2-components.dom.test.tsx"
],
"baseline": {
Expand Down
2 changes: 2 additions & 0 deletions docs/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

This repo uses one shared search experience across the global shell, dashboard result pages, and document-detail/source routes. Keep the behaviour page-aware but predictable.

Work-area tabs remain in page flow and follow their universal header's hide/reveal state. Their sticky hook batches scroll and geometry notifications into animation frames and rechecks compositor motion on completion rather than polling layout throughout the animation. Focus changes also recheck the sticky position. Work action docks keep their sticky page-flow placement; their focus clearance applies to both the document and `#main-content`, so installed phones receive the same clearance as browser tabs. Assessment browser and installed-layout regression coverage lives in `tests/ui-phone-scroll-assessments.spec.ts`; emulation does not establish physical iPhone acceptance.

## Page ownership model

| Page state | Composer placement | Reserve owner |
Expand Down
7 changes: 4 additions & 3 deletions scripts/phone-chrome-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,17 @@ const phoneChromeBrowserSpecPattern =
* A helper-only change is `playwrightHelper` scope, which selects the focused
* ownership journeys — four grepped cases in ONE of these files. That leaves a
* regression in a shared function (`readGeometry`, `installFlipCounter`,
* `dragScrollBy`) passing the phone-chrome gate while two of the helper's three
* consumers never run. So a change to the helper runs all of its consumers in
* `dragScrollBy`) passing the phone-chrome gate while other helper consumers
* never run. So a change to the helper runs all of its consumers in
* full, exactly as if each spec had been edited directly.
*
* `tests/verify-phone-chrome.test.ts` pins this list against the specs that
* actually import the helper, so a fourth sibling cannot be added without
* actually import the helper, so another sibling cannot be added without
* either joining this list or failing that test.
*/
const sharedPhoneScrollHelper = "tests/helpers/phone-scroll.ts";
const phoneScrollConsumerSpecs = [
"tests/ui-phone-scroll-assessments.spec.ts",
"tests/ui-phone-scroll.spec.ts",
"tests/ui-phone-scroll-page-owned.spec.ts",
"tests/ui-phone-scroll-routes.spec.ts",
Expand Down
11 changes: 10 additions & 1 deletion scripts/playwright-pr-shards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,20 @@ export const prUiSpecProfiles = Object.freeze([
},
{
// Unmeasured (new in Roster release two); estimated until a CI timing report exists.
// Moved to shard 3 when the assessment scrolling spec joined shard 1, keeping
// both groups inside the duration-balance ceilings.
file: "tests/ui-roster-team.spec.ts",
shard: 1,
shard: 3,
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
fullSeconds: 13.3,
criticalSeconds: 0,
},
{
// Estimated (new assessment scrolling coverage); re-measure after the first PR timing report.
file: "tests/ui-phone-scroll-assessments.spec.ts",
shard: 1,
fullSeconds: 25,
criticalSeconds: 0,
},
{
file: "tests/ui-phone-scroll-document-rail.spec.ts",
shard: 1,
Expand Down
10 changes: 10 additions & 0 deletions scripts/playwright-release-shards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export const releaseSpecSeconds = Object.freeze({
"tests/ui-overlap.spec.ts": 39.4,
"tests/ui-patient-number-field.spec.ts": 9.3,
"tests/ui-phone-motion.spec.ts": 18.0,
// Estimated (new assessment scrolling coverage; re-measure after the first release timing).
"tests/ui-phone-scroll-assessments.spec.ts": 70.0,
"tests/ui-phone-scroll-document-rail.spec.ts": 4.4,
"tests/ui-phone-scroll-page-owned.spec.ts": 49.2,
"tests/ui-phone-scroll-routes.spec.ts": 162.5,
Expand Down Expand Up @@ -113,6 +115,8 @@ export const releaseSpecSeconds = Object.freeze({
"tests/ui-overlap.spec.ts": 47.2,
"tests/ui-patient-number-field.spec.ts": 17.1,
"tests/ui-phone-motion.spec.ts": 19.6,
// Estimated (new assessment scrolling coverage; re-measure after the first release timing).
"tests/ui-phone-scroll-assessments.spec.ts": 85.0,
"tests/ui-phone-scroll-document-rail.spec.ts": 9.1,
"tests/ui-phone-scroll-page-owned.spec.ts": 81.5,
"tests/ui-phone-scroll-routes.spec.ts": 186.0,
Expand Down Expand Up @@ -162,6 +166,8 @@ export const releaseSpecSeconds = Object.freeze({
"tests/ui-overlap.spec.ts": 65.8,
"tests/ui-patient-number-field.spec.ts": 44.0,
"tests/ui-phone-motion.spec.ts": 45.8,
// Estimated (new assessment scrolling coverage; re-measure after the first release timing).
"tests/ui-phone-scroll-assessments.spec.ts": 90.0,
"tests/ui-phone-scroll-document-rail.spec.ts": 18.3,
"tests/ui-phone-scroll-page-owned.spec.ts": 146.2,
"tests/ui-phone-scroll-routes.spec.ts": 217.1,
Expand Down Expand Up @@ -211,6 +217,8 @@ export const releaseSpecSeconds = Object.freeze({
"tests/ui-overlap.spec.ts": 69.9,
"tests/ui-patient-number-field.spec.ts": 37.7,
"tests/ui-phone-motion.spec.ts": 43.6,
// Estimated (new assessment scrolling coverage; re-measure after the first release timing).
"tests/ui-phone-scroll-assessments.spec.ts": 90.0,
"tests/ui-phone-scroll-document-rail.spec.ts": 15.5,
"tests/ui-phone-scroll-page-owned.spec.ts": 127.3,
"tests/ui-phone-scroll-routes.spec.ts": 199.8,
Expand Down Expand Up @@ -265,6 +273,8 @@ export const releaseSpecSeconds = Object.freeze({
"tests/ui-overlap.spec.ts": 71.2,
"tests/ui-patient-number-field.spec.ts": 19.8,
"tests/ui-phone-motion.spec.ts": 19.9,
// Estimated (new assessment scrolling coverage; re-measure after the first release timing).
"tests/ui-phone-scroll-assessments.spec.ts": 90.0,
"tests/ui-phone-scroll-document-rail.spec.ts": 11.6,
"tests/ui-phone-scroll-page-owned.spec.ts": 99.4,
"tests/ui-phone-scroll-routes.spec.ts": 244.6,
Expand Down
3 changes: 2 additions & 1 deletion src/app/work-mode.css
Original file line number Diff line number Diff line change
Expand Up @@ -1983,7 +1983,8 @@ a.work-hero:focus-visible {

/* While a dock is on the page, a control that takes focus (a tab, the keyboard
opening on a field) scrolls clear of the dock rather than under it. */
html:has(.work-dock) {
html:has(.work-dock),
html:has(.work-dock) #main-content {
scroll-padding-bottom: calc(5.75rem + var(--safe-area-bottom, 0px));
}
Comment thread
BigSimmo marked this conversation as resolved.
Resolved

Expand Down
96 changes: 69 additions & 27 deletions src/components/work-frame/use-sticky-work-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,24 @@ export function useStickyWorkTabs(band: HTMLElement | null, navRef: RefObject<HT
const nav = navRef.current;
if (!band || !nav) return;
const root = document.documentElement;
const header = document.querySelector<HTMLElement>(".universal-header");
const collapse = header?.closest<HTMLElement>('[data-testid="universal-header-collapse"]');
const stack = header?.closest<HTMLElement>(".phone-sticky-header-stack");
// The box the band sticks in: the page box on an installed phone,
// otherwise the window.
let scroller: HTMLElement | null = null;
let frame = 0;
let slide: Animation | null = null;
let needsMeasure = false;
let needsBarSync = false;
const setAttribute = (name: string, value: boolean) => {
if (band.hasAttribute(name) !== value) band.toggleAttribute(name, value);
};
const setProperty = (element: HTMLElement, name: string, value: string) => {
if (element.style.getPropertyValue(name) !== value) element.style.setProperty(name, value);
};

const checkStuck = () => {
frame = 0;
if (!band.hasAttribute("data-pinned")) return;
// Read where the CSS holds the band now, not where it was at the last
// measure: the phone reserve behind `--work-chrome-h` is published after
Expand All @@ -59,16 +69,25 @@ export function useStickyWorkTabs(band: HTMLElement | null, navRef: RefObject<HT
const edge = scroller ? scroller.getBoundingClientRect().top + scroller.clientTop : 0;
// Mid-slide, read where the band is held, not where it has slid to.
const slid = Number.parseFloat(style.translate.split(" ")[1] ?? "0") || 0;
band.toggleAttribute("data-stuck", band.getBoundingClientRect().top - slid - edge <= stickTop + 1);
// The band moves with no scroll event while it slides, so keep reading.
// That includes a CSS transition: under reduced motion the app gives
// every property a near-zero one, so `top` still reads its old value the
// frame the bar hides, and a band left in its own place stayed marked
// stuck with its title trimmed off (found 10 Oct 2026).
if (band.getAnimations?.().some((animation) => animation.playState === "running")) onScroll();
setAttribute("data-stuck", band.getBoundingClientRect().top - slid - edge <= stickTop + 1);
// Translate does not change the sticky position. Recheck on real scroll,
// geometry changes and animation completion instead of polling layout on
// every compositor-animation frame while the reader is stationary.
};
const onScroll = () => {
if (!frame) frame = window.requestAnimationFrame(checkStuck);
if (!frame)
frame = window.requestAnimationFrame(() => {
frame = 0;
const measureNow = needsMeasure;
const syncNow = needsBarSync;
needsMeasure = false;
needsBarSync = false;
if (measureNow) measure();
if (syncNow) syncBar();
// Geometry writes settle before the next position check.
if (measureNow || syncNow) onScroll();
else checkStuck();
});
};

// The bar hid or came back. `data-bar-hidden` drops the band's sticky
Expand All @@ -78,12 +97,12 @@ export function useStickyWorkTabs(band: HTMLElement | null, navRef: RefObject<HT
// animation of `translate` from the measured gap to zero, so it stays
// smooth on a busy phone, and the sticky engine owns where the band ends.
const syncBar = () => {
const hidden = document.querySelector('[data-scroll-hidden="true"] .universal-header') !== null;
const hidden = collapse?.dataset.scrollHidden === "true" || stack?.dataset.scrollHidden === "true";
if (band.hasAttribute("data-bar-hidden") === hidden) return;
const from = band.getBoundingClientRect().top;
slide?.cancel();
slide = null;
band.toggleAttribute("data-bar-hidden", hidden);
setAttribute("data-bar-hidden", hidden);
const gap = from - band.getBoundingClientRect().top;
if (Math.abs(gap) >= 1 && motionAllowed() && typeof band.animate === "function") {
const tokens = getComputedStyle(root);
Expand All @@ -92,50 +111,73 @@ export function useStickyWorkTabs(band: HTMLElement | null, navRef: RefObject<HT
duration: Number.parseFloat(token(hidden ? "--duration-slow" : "--duration-moderate")) || 240,
easing: token(hidden ? "--ease-chrome-hide" : "--ease-chrome-reveal") || "ease-out",
});
slide.onfinish = onScroll;
}
onScroll();
};

const measure = () => {
const bar = document.querySelector<HTMLElement>(".universal-header");
const tabsTop = nav.offsetTop;
const barHeight = bar?.offsetHeight ?? 0;
band.style.setProperty("--work-band-tabs-top", `${tabsTop}px`);
const barHeight = header?.offsetHeight ?? 0;
const tabsHeight = band.offsetHeight - tabsTop;
const nextScroller = stickyScroller(band);
// Read dimensions before publishing CSS; unchanged resize notifications
// must not create root-style mutations and redundant position checks.
setProperty(band, "--work-band-tabs-top", `${tabsTop}px`);
// Pinned, the band's top sits `tabsTop` above the bar's bottom edge, so
// shifting its dots by the difference keeps them on the bar's grid.
band.style.setProperty("--work-band-stuck-offset", `${tabsTop - barHeight}px`);
root.style.setProperty("--work-tabs-h", `${band.offsetHeight - tabsTop}px`);
if (bar) root.style.setProperty("--work-bar-h", `${barHeight}px`);
band.toggleAttribute("data-pinned", true);
scroller = stickyScroller(band);
checkStuck();
setProperty(band, "--work-band-stuck-offset", `${tabsTop - barHeight}px`);
setProperty(root, "--work-tabs-h", `${tabsHeight}px`);
if (header) setProperty(root, "--work-bar-h", `${barHeight}px`);
setAttribute("data-pinned", true);
scroller = nextScroller;
};

measure();
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
syncBar();
onScroll();
const onResize = () => {
needsMeasure = true;
onScroll();
};
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(onResize);
resize?.observe(band);
resize?.observe(nav);
// The bar (and the phone's overlay stack, whose height is the phone reserve)
// can change height with text size or rotation; the pinned offset follows.
for (const chrome of document.querySelectorAll<HTMLElement>(".universal-header, .phone-sticky-header-stack")) {
resize?.observe(chrome);
}
window.addEventListener("resize", measure);
window.addEventListener("resize", onResize);
// Capture hears the page box's scroll as well as the window's.
document.addEventListener("scroll", onScroll, { capture: true, passive: true });
syncBar();
const bar = typeof MutationObserver === "undefined" ? null : new MutationObserver(syncBar);
bar?.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["data-scroll-hidden"] });
const bar =
typeof MutationObserver === "undefined"
? null
: new MutationObserver(() => {
needsBarSync = true;
onScroll();
});
for (const owner of new Set([collapse, stack])) {
if (owner) bar?.observe(owner, { attributes: true, attributeFilter: ["data-scroll-hidden"] });
}
// The phone reserve lands on the root's style after a quiet window, later
// than any resize here, and moves where the band sticks with no scroll.
const reserve = typeof MutationObserver === "undefined" ? null : new MutationObserver(onScroll);
reserve?.observe(root, { attributes: true, attributeFilter: ["style"] });
// Focus changes the CSS sticky offset without necessarily scrolling. The
// completion event also covers reduced-motion's near-zero top transition.
band.addEventListener("focusin", onScroll);
band.addEventListener("focusout", onScroll);
band.addEventListener("transitionend", onScroll);
return () => {
bar?.disconnect();
reserve?.disconnect();
slide?.cancel();
window.removeEventListener("resize", measure);
window.removeEventListener("resize", onResize);
document.removeEventListener("scroll", onScroll, { capture: true });
band.removeEventListener("focusin", onScroll);
band.removeEventListener("focusout", onScroll);
band.removeEventListener("transitionend", onScroll);
if (frame) window.cancelAnimationFrame(frame);
resize?.disconnect();
band.removeAttribute("data-pinned");
Expand Down
Loading
Loading