diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md index 0e654aca23..fc885beddb 100644 --- a/docs/design-system/GATES.md +++ b/docs/design-system/GATES.md @@ -128,7 +128,7 @@ because it contributed nothing. `tests/ui-style-contract.spec.ts`) — do not re-litigate. Overlay z-index vs named `--z-*` tokens is **implemented-blocking** in the same spec (`overlay z-index computed tokens`); class-string assertions are forbidden; the -`rawCssZIndices` exception baseline stays at 8 in `globals.css`. Still planned +`rawCssZIndices` exception baseline stays at 4 in `globals.css`. Still planned (PR 1–2): Tailwind conflict order · v2 cascade, ancestor **and** same-node forms · target geometry · contrast · print token reset. Class-string assertions are not accepted for any of these — computed values only. diff --git a/src/app/globals.css b/src/app/globals.css index 2642b9d76d..57af6f986c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -501,6 +501,12 @@ --disabled: #8794a8; --focus: var(--clinical-accent); --overlay-backdrop: rgb(4 8 14 / 56%); + /* Documented stacking ladder (TOKENS). Live overlay/popover/modal/toast plus + v2 base/raised/chrome. Do not invent rungs; z-index:30 phone stack and + z-index:2 carets have no named token and stay literal. */ + --z-base: 0; + --z-raised: 10; + --z-chrome: 60; --z-overlay: 80; --z-popover: 95; --z-modal: 100; @@ -1250,9 +1256,9 @@ summary::-webkit-details-marker { 0 8px 24px rgb(16 24 40 / 8%); backdrop-filter: blur(18px) saturate(140%); transition: - border-color 180ms var(--ease-out-soft), - box-shadow 180ms var(--ease-out-soft), - transform 180ms var(--ease-spring); + border-color var(--duration-base) var(--ease-out-soft), + box-shadow var(--duration-base) var(--ease-out-soft), + transform var(--duration-base) var(--ease-spring); } } @@ -1282,6 +1288,7 @@ summary::-webkit-details-marker { position: absolute; inset-inline: 0; top: 0; + /* Below the documented ladder; no --z rung for underlay scrims. */ z-index: -1; height: max(6.5rem, calc(var(--safe-area-top) + 5.5rem)); overflow: hidden; @@ -1323,7 +1330,7 @@ summary::-webkit-details-marker { position: fixed; inset-inline: 0; bottom: 0; - z-index: 0; + z-index: var(--z-base); height: max(6.5rem, calc(var(--safe-area-bottom) + 5.25rem)); background: linear-gradient( 180deg, @@ -1351,7 +1358,7 @@ summary::-webkit-details-marker { .differentials-mobile-compare-fab { pointer-events: none; position: relative; - z-index: 10; + z-index: var(--z-raised); display: flex; width: 100%; justify-content: center; @@ -1381,6 +1388,7 @@ summary::-webkit-details-marker { letter-spacing: -0.01em; box-shadow: 0 18px 42px rgb(0 0 0 / 24%); touch-action: manipulation; + /* 160ms has no duration rung — do not invent one; nearest is --duration-quick (150ms). */ transition: transform 160ms var(--ease-spring), background-color 160ms var(--ease-out-soft), @@ -1411,7 +1419,7 @@ summary::-webkit-details-marker { .patient-details-fab { pointer-events: none; position: relative; - z-index: var(--z-raised, 10); + z-index: var(--z-raised); display: flex; width: 100%; justify-content: center; @@ -1492,7 +1500,7 @@ summary::-webkit-details-marker { and disabled behaviour come from the design system, per COMPONENTS.md 9.1. */ .therapy-compare-tray { position: relative; - z-index: var(--z-raised, 10); + z-index: var(--z-raised); display: flex; width: 100%; align-items: center; @@ -1606,7 +1614,7 @@ summary::-webkit-details-marker { inset-inline: 0; bottom: 0; top: auto; - z-index: 0; + z-index: var(--z-base); height: var(--footer-scrim-height); overflow: hidden; /* Localized glass only: tint around the pill, then return to true zero at @@ -1648,7 +1656,7 @@ summary::-webkit-details-marker { opacity: 1; visibility: visible; transition: - opacity 180ms var(--ease-out-soft), + opacity var(--duration-base) var(--ease-out-soft), visibility 0s linear; } @@ -1850,6 +1858,7 @@ summary::-webkit-details-marker { position: absolute; bottom: -0.42rem; left: var(--mode-action-caret-left, 2.4rem); + /* Caret stacking; no --z rung between base (0) and raised (10). */ z-index: 2; width: 0.9rem; height: 0.9rem; @@ -1867,6 +1876,7 @@ summary::-webkit-details-marker { position: absolute; top: -0.42rem; left: var(--mode-action-caret-left, 2.4rem); + /* Caret stacking; no --z rung between base (0) and raised (10). */ z-index: 2; width: 0.9rem; height: 0.9rem; @@ -1885,13 +1895,13 @@ summary::-webkit-details-marker { } .mode-action-surface[data-placement="up"] .mode-action-panel { - border-bottom-left-radius: 1rem; - border-bottom-right-radius: 1rem; + border-bottom-left-radius: var(--radius-xl); + border-bottom-right-radius: var(--radius-xl); } .mode-action-surface[data-placement="down"] .mode-action-panel { - border-top-left-radius: 1rem; - border-top-right-radius: 1rem; + border-top-left-radius: var(--radius-xl); + border-top-right-radius: var(--radius-xl); } .mode-action-mode-option:focus-visible { @@ -1921,9 +1931,9 @@ summary::-webkit-details-marker { text-align: left; color: var(--text-muted); transition: - background-color 150ms var(--ease-out-soft), - border-color 150ms var(--ease-out-soft), - color 150ms var(--ease-out-soft); + background-color var(--duration-quick) var(--ease-out-soft), + border-color var(--duration-quick) var(--ease-out-soft), + color var(--duration-quick) var(--ease-out-soft); } .mode-action-mode-option:hover { @@ -2078,10 +2088,10 @@ summary::-webkit-details-marker { place-items: center; border-radius: 999px; transition: - background-color 150ms var(--ease-out-soft), - color 150ms var(--ease-out-soft), - box-shadow 150ms var(--ease-out-soft), - transform 150ms var(--ease-spring); + background-color var(--duration-quick) var(--ease-out-soft), + color var(--duration-quick) var(--ease-out-soft), + box-shadow var(--duration-quick) var(--ease-out-soft), + transform var(--duration-quick) var(--ease-spring); } .chat-composer-icon-button { @@ -2163,6 +2173,7 @@ summary::-webkit-details-marker { } /* Mirrors the `motion-safe` variant: animate when the OS allows it, and also when the in-app Motion preference explicitly opts in over an OS reduce request. */ +/* 170ms has no duration rung — do not invent one; nearest is --duration-base (180ms). */ @media (prefers-reduced-motion: reduce) { html[data-motion="full"] .mode-action-surface[data-placement="up"] { animation: mode-action-fold-up 170ms var(--ease-out-soft) both; @@ -2254,6 +2265,7 @@ summary::-webkit-details-marker { font-weight: 700; box-shadow: 0 2px 10px rgb(16 24 40 / 6%); backdrop-filter: blur(14px); + /* 160ms has no duration rung — leave literal (search-chip chrome). */ transition: border-color 160ms var(--ease-out-soft), background-color 160ms var(--ease-out-soft), @@ -2612,6 +2624,7 @@ summary::-webkit-details-marker { text-align: left; text-overflow: ellipsis; white-space: nowrap; + /* 160ms has no duration rung — leave literal (suggestion-chip chrome). */ transition: border-color 160ms var(--ease-out-soft), background-color 160ms var(--ease-out-soft), @@ -2982,6 +2995,9 @@ summary::-webkit-details-marker { .answer-footer-search-dock[data-scroll-hidden="true"] .answer-footer-search-backdrop { opacity: 0; visibility: hidden; + /* Phone-chrome hide delay must stay 240ms (--duration-slow). Keep the + 0ms / 240ms pair literal: mobile-chrome-paint-contract pins this string, + and 0ms delays are not token-swapped. */ transition-delay: 0ms, 240ms; } @@ -3158,7 +3174,7 @@ summary::-webkit-details-marker { --pwa-motion-enter: 280ms; position: fixed; - z-index: 95; + z-index: var(--z-popover); left: max(0.5rem, var(--safe-area-left)); right: max(0.5rem, var(--safe-area-right)); bottom: var(--pwa-notice-bottom-gap); @@ -4034,6 +4050,7 @@ td, .phone-sticky-header-stack { position: sticky; top: 0; + /* Between raised (10) and chrome (60); no named rung. Do not retune. */ z-index: 30; } @@ -4227,9 +4244,9 @@ td, .source-capsule-face { border-color: var(--border); transition: - border-color 150ms var(--ease-out-soft), - box-shadow 150ms var(--ease-out-soft), - transform 150ms var(--ease-spring); + border-color var(--duration-quick) var(--ease-out-soft), + box-shadow var(--duration-quick) var(--ease-out-soft), + transform var(--duration-quick) var(--ease-spring); } .source-capsule-hit:hover .source-capsule-face { @@ -4258,7 +4275,7 @@ td, .polished-scroll { scrollbar-color: color-mix(in srgb, var(--border-strong) 65%, transparent) transparent; scrollbar-width: thin; - transition: scrollbar-color 200ms ease; + transition: scrollbar-color var(--duration-moderate) ease; } .polished-scroll:hover, @@ -4283,8 +4300,8 @@ td, background-color: color-mix(in srgb, var(--border-strong) 60%, transparent); background-clip: padding-box; border: 3px solid transparent; - border-radius: 999px; - transition: background-color 150ms ease; + border-radius: var(--radius-pill); + transition: background-color var(--duration-quick) ease; } .polished-scroll:hover::-webkit-scrollbar-thumb { @@ -4341,6 +4358,7 @@ html[data-motion="reduced"] .answer-activity-trace__sweep { .stagger-item { animation: cascade-fade-up var(--duration-moderate) var(--ease-out-soft) both; + /* 35ms stagger interval has no duration rung. */ animation-delay: calc(var(--stagger-index, 0) * 35ms); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 8548543137..ad144da0d9 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -532,9 +532,15 @@ function ClinicalDashboardContent({ if (!target) return; setActiveHash(href); const targetTop = target.getBoundingClientRect().top; - const top = ownsVerticalScroll(main) + const unclamped = ownsVerticalScroll(main) ? main.scrollTop + targetTop - main.getBoundingClientRect().top - 8 : window.scrollY + targetTop - 8; + // Clamp like settings-dialog: short #quotes|#images|#sources sections can + // compute a top past the runway. scrollSurface also clamps; keep the + // local clamp so this hash path does not fight use-hide-on-scroll. + const scroller = ownsVerticalScroll(main) ? main : (document.scrollingElement ?? document.documentElement); + const maxOffset = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const top = Math.min(Math.max(0, unclamped), maxOffset); scrollSurface(main, top); if (shouldUpdateHistory) window.history.replaceState(null, "", href); navSyncLockRef.current = window.setTimeout(() => { diff --git a/src/components/calculators/guided-flow.tsx b/src/components/calculators/guided-flow.tsx index 66497722cd..97a3335d4c 100644 --- a/src/components/calculators/guided-flow.tsx +++ b/src/components/calculators/guided-flow.tsx @@ -130,8 +130,8 @@ function QuestionScreen({ className="h-1.5 overflow-hidden rounded-full bg-[color:var(--surface-inset)]" >
0 ? stepIndex / total : 0})` }} />
diff --git a/src/components/clinical-dashboard/scroll-surface.ts b/src/components/clinical-dashboard/scroll-surface.ts index 2dfca54944..86dd0183ed 100644 --- a/src/components/clinical-dashboard/scroll-surface.ts +++ b/src/components/clinical-dashboard/scroll-surface.ts @@ -5,6 +5,12 @@ export function ownsVerticalScroll(element: HTMLElement) { return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay"; } +function clampScrollOffset(scroller: Element, target: ScrollSurfaceTarget) { + const maxOffset = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + if (target === "end") return maxOffset; + return Math.min(Math.max(0, target), maxOffset); +} + export function scrollSurface( element: HTMLElement | null, target: ScrollSurfaceTarget, @@ -12,9 +18,9 @@ export function scrollSurface( ) { if (!element) return; if (ownsVerticalScroll(element)) { - element.scrollTo({ top: target === "end" ? element.scrollHeight : target, behavior }); + element.scrollTo({ top: clampScrollOffset(element, target), behavior }); return; } const scrollingElement = document.scrollingElement ?? document.documentElement; - window.scrollTo({ top: target === "end" ? scrollingElement.scrollHeight : target, behavior }); + window.scrollTo({ top: clampScrollOffset(scrollingElement, target), behavior }); } diff --git a/src/components/ward-management/coordinator/coordinator.module.css b/src/components/ward-management/coordinator/coordinator.module.css index 7f79ff8bbb..6b4643ad54 100644 --- a/src/components/ward-management/coordinator/coordinator.module.css +++ b/src/components/ward-management/coordinator/coordinator.module.css @@ -1,4 +1,5 @@ .screen { + composes: descendantKillWithScroll from "../ward-reduced-motion.module.css"; --co-space-2: 0.125rem; --co-space-6: 0.375rem; --co-space-8: 0.5rem; @@ -1892,13 +1893,6 @@ } } -@media (prefers-reduced-motion: reduce) { - .screen * { - scroll-behavior: auto !important; - transition: none !important; - } -} - @media (forced-colors: active) { .queueRowSelected { border: 0.125rem solid Highlight; diff --git a/src/components/ward-management/officer/officer.module.css b/src/components/ward-management/officer/officer.module.css index 85ba1a60b0..c3e1d5a0b2 100644 --- a/src/components/ward-management/officer/officer.module.css +++ b/src/components/ward-management/officer/officer.module.css @@ -4,6 +4,7 @@ * needs is declared here, never borrowed from another module's scope. */ .screen { + composes: descendantKill from "../ward-reduced-motion.module.css"; --of-space-4: 0.25rem; --of-space-6: 0.375rem; --of-space-8: 0.5rem; @@ -242,12 +243,6 @@ } } -@media (prefers-reduced-motion: reduce) { - .screen * { - transition: none !important; - } -} - /* * Below 40rem the icon rail is gone and `ward-sidebar.module.css`'s fixed phone bar takes its * place. The bar is fixed rather than sticky because it has to sit above ten different shells diff --git a/src/components/ward-management/tracker/live-tracker.module.css b/src/components/ward-management/tracker/live-tracker.module.css index a4b4d1078b..e4f9ef4238 100644 --- a/src/components/ward-management/tracker/live-tracker.module.css +++ b/src/components/ward-management/tracker/live-tracker.module.css @@ -4,6 +4,7 @@ * (see `officer.module.css`'s own note on the same convention). */ .screen { + composes: descendantKill from "../ward-reduced-motion.module.css"; --tr-space-4: 0.25rem; --tr-space-6: 0.375rem; --tr-space-8: 0.5rem; @@ -199,12 +200,6 @@ font-weight: 700; } -@media (prefers-reduced-motion: reduce) { - .screen * { - transition: none !important; - } -} - /* * Below 40rem the icon rail is gone and `ward-sidebar.module.css`'s fixed phone bar takes its * place. The bar is fixed rather than sticky because it has to sit above ten different shells diff --git a/src/components/ward-management/ward-demo-controls.module.css b/src/components/ward-management/ward-demo-controls.module.css index 82afc4bba2..75cd428a3b 100644 --- a/src/components/ward-management/ward-demo-controls.module.css +++ b/src/components/ward-management/ward-demo-controls.module.css @@ -11,6 +11,7 @@ * distinction in words (never colour alone). */ .demo { + composes: descendantKill from "./ward-reduced-motion.module.css"; --dc-warn: var(--warning-text); --dc-warn-soft: var(--warning-bg); --dc-warn-border: var(--warning-border); diff --git a/src/components/ward-management/ward-management-modes.module.css b/src/components/ward-management/ward-management-modes.module.css index f8653c282b..764d103cd0 100644 --- a/src/components/ward-management/ward-management-modes.module.css +++ b/src/components/ward-management/ward-management-modes.module.css @@ -1,4 +1,5 @@ .modeShell { + composes: descendantKillWithScroll from "./ward-reduced-motion.module.css"; --ward-blue: var(--clinical-accent); --ward-blue-soft: var(--clinical-accent-soft); --ward-blue-border: var(--clinical-accent-border); @@ -1005,13 +1006,6 @@ } } -@media (prefers-reduced-motion: reduce) { - .modeShell * { - scroll-behavior: auto !important; - transition: none !important; - } -} - @media (forced-colors: active) { .candidateRowSelected, .primaryButton { diff --git a/src/components/ward-management/ward-management-network.module.css b/src/components/ward-management/ward-management-network.module.css index 5c16196349..da9ec76233 100644 --- a/src/components/ward-management/ward-management-network.module.css +++ b/src/components/ward-management/ward-management-network.module.css @@ -1,4 +1,5 @@ .networkPage { + composes: descendantKill from "./ward-reduced-motion.module.css"; --net-canvas: var(--surface); --net-subtle: var(--surface-subtle); --net-chrome: var(--surface-chrome); @@ -751,12 +752,6 @@ } } -@media (prefers-reduced-motion: reduce) { - .networkPage * { - transition: none !important; - } -} - /* High Contrast Mode. Selection and routing state on this screen is safety information (which service a patient is routed to), and under forced-colors the accent hues these states rely on are discarded. Mirrors the blocks the diff --git a/src/components/ward-management/ward-management.module.css b/src/components/ward-management/ward-management.module.css index 2d78972ed8..f2da3dc0f0 100644 --- a/src/components/ward-management/ward-management.module.css +++ b/src/components/ward-management/ward-management.module.css @@ -250,6 +250,14 @@ gap: var(--ward-space-6); } +.railLink { + composes: namedKill from "./ward-reduced-motion.module.css"; +} + +.railLinkActive { + composes: namedKill from "./ward-reduced-motion.module.css"; +} + .railLink, .railLinkActive { display: grid; @@ -883,13 +891,6 @@ } } -@media (prefers-reduced-motion: reduce) { - .railLink, - .railLinkActive { - transition: none; - } -} - @media (forced-colors: active) { .railLinkActive, .stageActive { diff --git a/src/components/ward-management/ward-reduced-motion.module.css b/src/components/ward-management/ward-reduced-motion.module.css new file mode 100644 index 0000000000..a6944cb125 --- /dev/null +++ b/src/components/ward-management/ward-reduced-motion.module.css @@ -0,0 +1,32 @@ +/* + * DS-P2-16: shared Ward reduced-motion kill. + * Compose onto the local owner class. Named vs descendant * !important + * specificity is preserved by which class you compose. scroll-behavior: auto + * lives only on descendantKillWithScroll (modes + coordinator). Do not merge + * Ward spacing ladders (--ward-space-*, --wd-space-*, --co-space-*) here. + * Do not use src/lib/scroll-behavior.ts from CSS. + */ + +.namedKill { +} + +.descendantKill { +} + +.descendantKillWithScroll { +} + +@media (prefers-reduced-motion: reduce) { + .namedKill { + transition: none; + } + + .descendantKill * { + transition: none !important; + } + + .descendantKillWithScroll * { + scroll-behavior: auto !important; + transition: none !important; + } +} diff --git a/src/components/ward-management/ward-role-switcher.module.css b/src/components/ward-management/ward-role-switcher.module.css index 0613056e7f..3b19fb083d 100644 --- a/src/components/ward-management/ward-role-switcher.module.css +++ b/src/components/ward-management/ward-role-switcher.module.css @@ -6,6 +6,7 @@ * inside it — this stays self-sufficient regardless of where it is placed. */ .switcher { + composes: descendantKill from "./ward-reduced-motion.module.css"; --rs-blue: var(--clinical-accent); --rs-blue-soft: var(--clinical-accent-soft); --rs-blue-border: var(--clinical-accent-border); diff --git a/src/components/ward-management/ward-sidebar.module.css b/src/components/ward-management/ward-sidebar.module.css index 814e25168c..4ae16b2892 100644 --- a/src/components/ward-management/ward-sidebar.module.css +++ b/src/components/ward-management/ward-sidebar.module.css @@ -156,6 +156,14 @@ text-transform: uppercase; } +.link { + composes: namedKill from "./ward-reduced-motion.module.css"; +} + +.linkActive { + composes: namedKill from "./ward-reduced-motion.module.css"; +} + .link, .linkActive { display: flex; @@ -343,13 +351,6 @@ } } -@media (prefers-reduced-motion: reduce) { - .link, - .linkActive { - transition: none; - } -} - @media (forced-colors: active) { .linkActive { border: 0.125rem solid Highlight; diff --git a/tests/design-system-contract-utils.test.ts b/tests/design-system-contract-utils.test.ts index 04269a6a5e..3827966adf 100644 --- a/tests/design-system-contract-utils.test.ts +++ b/tests/design-system-contract-utils.test.ts @@ -446,12 +446,12 @@ describe("design-system contract helpers", () => { expect(countRawCssZIndicesInSource(".a{z-index: 95;}.b{z-index:-1;}")).toBe(2); }); - it("pins the globals.css raw CSS z-index exception baseline at 8 (DS-P3-06)", async () => { + it("pins the globals.css raw CSS z-index exception baseline at 4 (DS-P3-06)", async () => { const { readFileSync } = await import("node:fs"); const { join } = await import("node:path"); const globals = readFileSync(join(process.cwd(), "src", "app", "globals.css"), "utf8"); - expect(countRawCssZIndicesInSource(globals)).toBe(8); - expect(countRawCssZIndicesInSource(`${globals}\n.ds-p3-06-probe{z-index:9999}`)).toBe(9); + expect(countRawCssZIndicesInSource(globals)).toBe(4); + expect(countRawCssZIndicesInSource(`${globals}\n.ds-p3-06-probe{z-index:9999}`)).toBe(5); }); it("counts a same-file text-sm + text-sm-minus mix as one warn/ratchet hit, not a hard zero", () => { diff --git a/tests/ui-style-contract.spec.ts b/tests/ui-style-contract.spec.ts index c3c203d40a..c86a7e947e 100644 --- a/tests/ui-style-contract.spec.ts +++ b/tests/ui-style-contract.spec.ts @@ -545,7 +545,7 @@ const OVERLAY_Z_RUNGS = [ { host: "toast", token: "--z-toast", component: "Toast" }, ] as const; -const RAW_CSS_Z_INDEX_EXCEPTION_BASELINE = 8; +const RAW_CSS_Z_INDEX_EXCEPTION_BASELINE = 4; const OFF_LADDER_INLINE_Z_INDEX = "9999"; function computedZMatchesToken(computedZ: string, tokenValue: string) { @@ -604,7 +604,7 @@ test.describe("overlay z-index computed tokens", () => { ).toBe(false); }); - test("rawCssZIndices exception baseline in globals.css stays at 8 and rejects expansion", async () => { + test("rawCssZIndices exception baseline in globals.css stays at 4 and rejects expansion", async () => { const fs = await import("node:fs"); const path = await import("node:path"); const { countRawCssZIndicesInSource } = await import("../scripts/design-system-contract-utils.mjs");