From 17582a9574eac56b71b3d99a05fd3a4f5bf90c2f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:36:57 +0800 Subject: [PATCH 1/3] fix(ui): stabilize assessment scrolling and sticky work tabs --- docs/search-chrome-behaviour.md | 2 + scripts/phone-chrome-plan.mjs | 7 +- src/app/work-mode.css | 3 +- .../work-frame/use-sticky-work-tabs.ts | 96 +++++++--- tests/ui-phone-scroll-assessments.spec.ts | 172 ++++++++++++++++++ tests/use-sticky-work-tabs.dom.test.tsx | 74 ++++++++ 6 files changed, 323 insertions(+), 31 deletions(-) create mode 100644 tests/ui-phone-scroll-assessments.spec.ts diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index f7e4700e91..d83daa528b 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -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 | diff --git a/scripts/phone-chrome-plan.mjs b/scripts/phone-chrome-plan.mjs index 982fc1d3bb..2bd67daa4a 100644 --- a/scripts/phone-chrome-plan.mjs +++ b/scripts/phone-chrome-plan.mjs @@ -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", diff --git a/src/app/work-mode.css b/src/app/work-mode.css index c641100884..1767e0c50d 100644 --- a/src/app/work-mode.css +++ b/src/app/work-mode.css @@ -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)); } diff --git a/src/components/work-frame/use-sticky-work-tabs.ts b/src/components/work-frame/use-sticky-work-tabs.ts index d57bdfb936..92fcd238b6 100644 --- a/src/components/work-frame/use-sticky-work-tabs.ts +++ b/src/components/work-frame/use-sticky-work-tabs.ts @@ -38,14 +38,24 @@ export function useStickyWorkTabs(band: HTMLElement | null, navRef: RefObject(".universal-header"); + const collapse = header?.closest('[data-testid="universal-header-collapse"]'); + const stack = header?.closest(".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 @@ -59,16 +69,25 @@ export function useStickyWorkTabs(band: HTMLElement | null, navRef: RefObject 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 @@ -78,12 +97,12 @@ export function useStickyWorkTabs(band: HTMLElement | null, navRef: RefObject { - 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); @@ -92,27 +111,35 @@ export function useStickyWorkTabs(band: HTMLElement | null, navRef: RefObject { - const bar = document.querySelector(".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) @@ -120,22 +147,37 @@ export function useStickyWorkTabs(band: HTMLElement | null, navRef: RefObject(".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"); diff --git a/tests/ui-phone-scroll-assessments.spec.ts b/tests/ui-phone-scroll-assessments.spec.ts new file mode 100644 index 0000000000..3a9fe749aa --- /dev/null +++ b/tests/ui-phone-scroll-assessments.spec.ts @@ -0,0 +1,172 @@ +import { writeFile } from "node:fs/promises"; +import { expect, test } from "playwright/test"; +import { + blockExternalRequests, + dragScrollBy, + forceCompiledStandalonePhoneCss, + gotoPhoneSurface, + phoneViewport, + readGeometry, +} from "./helpers/phone-scroll"; + +test.beforeEach(async ({ page }) => { + await blockExternalRequests(page); + await page.setViewportSize(phoneViewport); +}); + +for (const standalone of [false, true]) { + for (const reducedMotion of ["no-preference", "reduce"] as const) { + test(`assessment scrolling stays stable in ${standalone ? "installed layout" : "browser"}, ${reducedMotion}`, async ({ + page, + }, testInfo) => { + await page.emulateMedia({ reducedMotion }); + await gotoPhoneSurface(page, "/teaching/assessments"); + await expect(page.getByTestId("assess-doctor-hero")).toBeVisible(); + if (standalone) expect(await forceCompiledStandalonePhoneCss(page)).toBeGreaterThan(0); + await expect.poll(async () => (await readGeometry(page)).scrollOwner).toBe(standalone ? "main" : "document"); + const band = page.getByTestId("mode-band"); + const collapse = page.getByTestId("universal-header-collapse"); + + // Small steps exercise threshold crossings; large steps exercise flicks. + // Compare the settled offset with delivered travel to catch geometry + // feedback that moves the page after the gesture has stopped. + for (const delta of [180, 90, -80, 130, -60, 45, -35, 90]) { + const before = await readGeometry(page); + const travelled = await dragScrollBy(page, delta, Math.abs(delta) > 100 ? 18 : Math.abs(delta)); + await page.waitForTimeout(350); + const after = await readGeometry(page); + expect( + Math.abs(after.scrollTop - before.scrollTop - travelled), + "no post-gesture scroll jump", + ).toBeLessThanOrEqual(2); + expect(after.horizontalOverflow).toBeLessThanOrEqual(1); + const hidden = (await collapse.getAttribute("data-scroll-hidden")) === "true"; + expect((await band.getAttribute("data-bar-hidden")) !== null).toBe(hidden); + if (hidden && (await band.getAttribute("data-stuck")) !== null) { + const bottom = await band.locator("nav").evaluate((nav) => nav.getBoundingClientRect().bottom); + expect(bottom, "hidden tabs leave no strip over the content").toBeLessThanOrEqual(1); + } + } + + await dragScrollBy(page, 5000, 500); + const bottom = await readGeometry(page); + await page.waitForTimeout(350); + expect(Math.abs((await readGeometry(page)).scrollTop - bottom.scrollTop)).toBeLessThanOrEqual(2); + await expect(page.locator("#assess-made-up-date")).toBeInViewport(); + const screenshot = testInfo.outputPath("assessment-scroll.png"); + await page.screenshot({ path: screenshot }); + await testInfo.attach(`assessments-${standalone ? "installed" : "browser"}-${reducedMotion}`, { + path: screenshot, + contentType: "image/png", + }); + + // Bring a hidden tab back by keyboard focus, then change pages from a + // scrolled state. Focused navigation must remain clickable. + const progress = band.getByRole("link", { name: "Progress", exact: true }); + await progress.focus(); + await expect(progress).toBeInViewport({ ratio: 1 }); + await progress.press("Enter"); + await expect(page).toHaveURL(/view=progress/); + await expect(page.locator("[data-screen-heading]").first()).toBeFocused(); + await page.goto("/teaching/assessments"); + await expect(page.getByTestId("assess-doctor-hero")).toBeVisible(); + await page.getByRole("button", { name: /^Request an EPA From/ }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await page.getByText("I supervise", { exact: true }).click(); + await expect(page).toHaveURL(/as=supervisor/); + expect((await readGeometry(page)).horizontalOverflow).toBeLessThanOrEqual(1); + }); + } +} + +test("assessment controls fit all required widths, enlarged text and forced colours", async ({ page }) => { + await gotoPhoneSurface(page, "/teaching/assessments"); + await expect(page.getByTestId("assess-doctor-hero")).toBeVisible(); + for (const width of [320, 390, 639, 768, 1440, 1920]) { + await page.setViewportSize({ width, height: 844 }); + await expect.poll(async () => (await readGeometry(page)).horizontalOverflow).toBeLessThanOrEqual(1); + } + await page.setViewportSize(phoneViewport); + await page.addStyleTag({ content: "html { font-size: 200% !important; }" }); + await expect.poll(async () => (await readGeometry(page)).horizontalOverflow).toBeLessThanOrEqual(1); + await page.emulateMedia({ forcedColors: "active", reducedMotion: "reduce" }); + const action = page.getByRole("link", { name: "Start end-of-term", exact: true }); + await action.focus(); + await expect(action).toBeInViewport({ ratio: 1 }); + await page.setViewportSize({ width: 600, height: 390 }); + await expect.poll(async () => (await readGeometry(page)).horizontalOverflow).toBeLessThanOrEqual(1); + await page.setViewportSize(phoneViewport); + await action.focus(); + await expect(action).toBeInViewport({ ratio: 1 }); +}); + +test("installed assessment focus clears the bottom action dock", async ({ page }) => { + await gotoPhoneSurface(page, "/teaching/assessments"); + await expect(page.getByTestId("assess-doctor-hero")).toBeVisible(); + expect(await forceCompiledStandalonePhoneCss(page)).toBeGreaterThan(0); + const clearance = await page.locator("#main-content").evaluate((main) => { + const css = getComputedStyle(main); + return { bottom: Number.parseFloat(css.scrollPaddingBottom), top: Number.parseFloat(css.scrollPaddingTop) }; + }); + expect(clearance.bottom).toBeGreaterThanOrEqual(34 + 5.75 * 16); + expect(clearance.top).toBeGreaterThan(59); + const help = page.getByRole("link", { name: "Help and words Every abbreviation in plain words", exact: true }); + await help.focus(); + await expect(help).toBeInViewport({ ratio: 1 }); + const bounds = await help.boundingBox(); + const dock = await page.locator(".work-dock").boundingBox(); + expect(bounds).not.toBeNull(); + expect(dock).not.toBeNull(); + expect(bounds!.y + bounds!.height, "focused link stays above the action").toBeLessThanOrEqual(dock!.y + 1); +}); + +test("assessment direction changes capture a warmed performance trace", async ({ page, browserName }, testInfo) => { + test.skip(browserName !== "chromium", "Chrome tracing is developer evidence, not a Safari performance claim"); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await gotoPhoneSurface(page, "/teaching/assessments"); + await expect(page.getByTestId("assess-doctor-hero")).toBeVisible(); + await dragScrollBy(page, 180, 18); + await dragScrollBy(page, -180, 18); + await page.waitForTimeout(400); + const session = await page.context().newCDPSession(page); + const complete = new Promise<{ stream?: string }>((resolve) => session.once("Tracing.tracingComplete", resolve)); + await session.send("Tracing.start", { + categories: "devtools.timeline,toplevel,disabled-by-default-devtools.timeline", + transferMode: "ReturnAsStream", + }); + for (const delta of [180, 90, -90, 120, -80]) await dragScrollBy(page, delta, 18); + await page.waitForTimeout(400); + await session.send("Tracing.end"); + const { stream } = await complete; + if (!stream) throw new Error("The browser did not return the captured scroll trace"); + let data = ""; + for (;;) { + const chunk = await session.send("IO.read", { handle: stream }); + data += chunk.base64Encoded ? Buffer.from(chunk.data, "base64").toString("utf8") : chunk.data; + if (chunk.eof) break; + } + await session.send("IO.close", { handle: stream }); + await session.detach(); + const tracePath = testInfo.outputPath("assessment-scroll-performance-trace.json"); + await writeFile(tracePath, data); + await testInfo.attach("assessment-scroll-performance-trace", { path: tracePath, contentType: "application/json" }); + const trace = JSON.parse(data) as { traceEvents: { name: string; dur?: number }[] }; + const layouts = trace.traceEvents.filter((event) => event.name === "Layout"); + const tasks = trace.traceEvents.filter((event) => event.name === "RunTask"); + expect(tasks.length, "the trace includes task events, so absent long tasks are observable").toBeGreaterThan(0); + const summary = { + layoutCount: layouts.length, + maxLayoutMs: Math.max(0, ...layouts.map((event) => (event.dur ?? 0) / 1000)), + taskCount: tasks.length, + longTaskMs: tasks.filter((event) => (event.dur ?? 0) >= 50_000).map((event) => event.dur! / 1000), + note: "Local production Chromium trace; whole-page measurements, not physical iPhone evidence", + }; + const summaryPath = testInfo.outputPath("assessment-scroll-performance-summary.json"); + await writeFile(summaryPath, JSON.stringify(summary, null, 2)); + await testInfo.attach("assessment-scroll-performance-summary", { + path: summaryPath, + contentType: "application/json", + }); +}); diff --git a/tests/use-sticky-work-tabs.dom.test.tsx b/tests/use-sticky-work-tabs.dom.test.tsx index c3d5a9a55c..0a6afa8519 100644 --- a/tests/use-sticky-work-tabs.dom.test.tsx +++ b/tests/use-sticky-work-tabs.dom.test.tsx @@ -27,6 +27,80 @@ function rectAt(top: number): DOMRect { afterEach(() => vi.restoreAllMocks()); describe("useStickyWorkTabs", () => { + it("coalesces scroll bursts and does not poll geometry while a compositor animation runs", () => { + let nextId = 0; + const frames = new Map(); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + frames.set(++nextId, callback); + return nextId; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => { + frames.delete(id); + }); + const flush = () => { + const pending = [...frames.values()]; + frames.clear(); + act(() => pending.forEach((callback) => callback(0))); + }; + const { getByTestId, unmount } = render(); + flush(); + const band = getByTestId("band"); + const rect = vi.spyOn(band, "getBoundingClientRect").mockReturnValue(rectAt(16)); + Object.defineProperty(band, "getAnimations", { + configurable: true, + value: () => [{ playState: "running" }], + }); + act(() => { + for (let i = 0; i < 20; i++) document.dispatchEvent(new Event("scroll")); + }); + expect(frames.size).toBe(1); + flush(); + expect(rect).toHaveBeenCalledTimes(1); + expect(frames.size).toBe(0); + expect(band.hasAttribute("data-stuck")).toBe(true); + + document.dispatchEvent(new Event("scroll")); + expect(frames.size).toBe(1); + unmount(); + expect(frames.size).toBe(0); + }); + + it("follows its header owner but ignores unrelated footer visibility mutations", async () => { + const collapse = document.createElement("div"); + collapse.dataset.testid = "universal-header-collapse"; + collapse.innerHTML = '
'; + const footer = document.createElement("div"); + document.body.append(collapse, footer); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callback(0); + return 0; + }); + const { getByTestId, unmount } = render(); + const band = getByTestId("band"); + const rect = vi.spyOn(band, "getBoundingClientRect").mockReturnValue(rectAt(16)); + try { + await act(async () => { + footer.dataset.scrollHidden = "true"; + await Promise.resolve(); + }); + expect(rect).not.toHaveBeenCalled(); + await act(async () => { + collapse.dataset.scrollHidden = "true"; + await Promise.resolve(); + }); + expect(band.hasAttribute("data-bar-hidden")).toBe(true); + await act(async () => { + delete collapse.dataset.scrollHidden; + await Promise.resolve(); + }); + expect(band.hasAttribute("data-bar-hidden")).toBe(false); + } finally { + unmount(); + collapse.remove(); + footer.remove(); + } + }); + it("marks the band stuck against where the CSS holds it now, not at the last measure", () => { // Run each frame at once; returning 0 leaves no frame pending. vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { From ce999cbf87c4d01fcea8fc15d7b05d6f28a11e6d Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:55:28 +0000 Subject: [PATCH 2/3] fix: Register assessment spec in browser shard manifests --- scripts/playwright-pr-shards.mjs | 7 +++++++ scripts/playwright-release-shards.mjs | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/scripts/playwright-pr-shards.mjs b/scripts/playwright-pr-shards.mjs index babeafb8f3..5a6e5cca1c 100644 --- a/scripts/playwright-pr-shards.mjs +++ b/scripts/playwright-pr-shards.mjs @@ -288,6 +288,13 @@ export const prUiSpecProfiles = Object.freeze([ 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: 3, + fullSeconds: 25, + criticalSeconds: 0, + }, { file: "tests/ui-phone-scroll-document-rail.spec.ts", shard: 1, diff --git a/scripts/playwright-release-shards.mjs b/scripts/playwright-release-shards.mjs index 91a80a666c..4e4c691394 100644 --- a/scripts/playwright-release-shards.mjs +++ b/scripts/playwright-release-shards.mjs @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, From 34866cf1e95d83525d2d5ccbaa1e1fded1aeaf33 Mon Sep 17 00:00:00 2001 From: BigSimmo Date: Sun, 11 Oct 2026 17:20:38 +0800 Subject: [PATCH 3/3] fix(ci): refresh design-system adoption manifest and rebalance PR UI shards for assessment spec --- docs/design-system/adoption-manifest.json | 1 + scripts/playwright-pr-shards.mjs | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 1a616d3f58..953a1f3717 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -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": { diff --git a/scripts/playwright-pr-shards.mjs b/scripts/playwright-pr-shards.mjs index 5a6e5cca1c..fe1d87c764 100644 --- a/scripts/playwright-pr-shards.mjs +++ b/scripts/playwright-pr-shards.mjs @@ -283,15 +283,17 @@ 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, 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: 3, + shard: 1, fullSeconds: 25, criticalSeconds: 0, },