From be245f442f7b8601474364937c5970d716a41feb Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:44:01 +0000 Subject: [PATCH 1/3] feat: unify kit motion and reduced-motion preference --- packages/app/src/kit/styles.css | 35 ++++++++++++ packages/app/src/kit/ui.tsx | 26 +++++++-- tests/e2e/chrome.spec.ts | 4 +- tests/e2e/kit-motion.spec.ts | 99 +++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/kit-motion.spec.ts diff --git a/packages/app/src/kit/styles.css b/packages/app/src/kit/styles.css index 462aa279..88a104e4 100644 --- a/packages/app/src/kit/styles.css +++ b/packages/app/src/kit/styles.css @@ -591,3 +591,38 @@ a.chip { display: inline-flex; align-items: center; text-decoration: none; } .space-empty { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 32px 20px; border-radius: var(--r-card); background: var(--card); box-shadow: var(--ring); text-align: center; color: var(--t2); } .space-empty h3 { margin: 10px 0 0; font-size: 13px; font-weight: 500; color: var(--t1); } .space-empty p { margin: 0; } + +:root { + --motion-fast: 120ms; + --motion-base: 220ms; + --motion-enter: 320ms; + --motion-exit: 180ms; + --d-press: var(--motion-fast); + --d-micro: var(--motion-fast); + --d-chrome: var(--motion-base); + --d-swap: var(--motion-enter); + --d-layout: var(--motion-enter); + --d-morph: var(--motion-enter); +} +.rail-btn { + transition: background var(--motion-fast) linear, color var(--motion-fast) linear, transform var(--motion-fast) var(--ease-out), opacity var(--motion-fast) linear; +} +.rail-btn:active:not(:disabled) { scale: none; transform: scale(.97); opacity: .9; } +/* Base controls reset outlines; keyboard focus must win those resets. */ +:focus-visible, :is(button, input, textarea, a[href], [tabindex]):focus-visible, +.theme button:focus-visible, .pg-hrow:focus-visible, .row:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 2px; +} +.theme:focus-within { overflow: visible; } +html[data-reduce-motion] *, html[data-reduce-motion] *::before, html[data-reduce-motion] *::after { + transition-duration: 0s !important; + animation: none !important; +} +html[data-reduce-motion] .rail-btn:active { transform: none; } +html[data-reduce-motion] :is(.thinking, .shimmer span) { color: var(--t2) !important; background: none !important; } +html[data-reduce-motion]::view-transition-group(*), html[data-reduce-motion]::view-transition-old(*), html[data-reduce-motion]::view-transition-new(*) { animation: none !important; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; } + .rail-btn:active { transform: none; } +} diff --git a/packages/app/src/kit/ui.tsx b/packages/app/src/kit/ui.tsx index bc942469..8f54fd23 100644 --- a/packages/app/src/kit/ui.tsx +++ b/packages/app/src/kit/ui.tsx @@ -7,6 +7,17 @@ import { Toast } from "@base-ui/react/toast"; import { Icon, Gel } from "../icons/Icon"; import { useT } from "../i18n"; +// The existing Settings switch persists this device preference in its change callback. +function syncReducedMotion() { + if (typeof document === "undefined") return; + document.documentElement.toggleAttribute("data-reduce-motion", localStorage.getItem("cortex.pref.appearance.reduceMotion") === "true"); +} +syncReducedMotion(); +if (typeof document !== "undefined") window.addEventListener("storage", (event) => { + if (event.key === "cortex.pref.appearance.reduceMotion" || event.key === null) syncReducedMotion(); +}); +const reducedMotion = () => document.documentElement.hasAttribute("data-reduce-motion") || matchMedia("(prefers-reduced-motion: reduce)").matches; + export { Icon, Gel }; export function Tip({ label, kbd, children, side = "bottom" }: { label: string; kbd?: string; children: React.ReactElement; side?: "top" | "bottom" | "left" | "right" }) { @@ -69,7 +80,7 @@ export function Segmented({ items, value: external, onChange, resetKey }: { item const to = [...l.querySelectorAll(".seg-tab")].find((tab) => tab.dataset.value === value); if (!to) return; const end = { translate: `${to.offsetLeft}px 0`, width: `${to.offsetWidth}px` }; - if (!placed.current || matchMedia("(prefers-reduced-motion: reduce)").matches) { placed.current = true; Object.assign(i.style, end); return; } + if (!placed.current || reducedMotion()) { placed.current = true; Object.assign(i.style, end); return; } const from = i.getBoundingClientRect(), parent = l.getBoundingClientRect(); const left = Math.min(from.left - parent.left, to.offsetLeft), right = Math.max(from.right - parent.left, to.offsetLeft + to.offsetWidth); let cancelled = false; @@ -95,7 +106,7 @@ export function Segmented({ items, value: external, onChange, resetKey }: { item pending.current = undefined; if (next !== (requested.current?.value ?? committed.current)) requested.current = { value: next, key: onChange(next) ?? undefined }; }; - if (matchMedia("(prefers-reduced-motion: reduce)").matches) commit(); + if (reducedMotion()) commit(); else pending.current = window.setTimeout(commit, 610); }}> @@ -107,7 +118,10 @@ export function Segmented({ items, value: external, onChange, resetKey }: { item } export function Switch(p: { checked?: boolean; defaultChecked?: boolean; disabled?: boolean; onCheckedChange?: (v: boolean) => void; "aria-label"?: string }) { - return ; + return { + p.onCheckedChange?.(checked); + syncReducedMotion(); + }}>; } export function ProgressCard({ label, done, total, onClick }: { label: string; done: number; total: number; onClick?: () => void }) { @@ -148,7 +162,7 @@ export function ModeSwitcher({ mode, onMode }: { mode: Mode; onMode: (m: Mode) = return ( { - if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; + if (reducedMotion()) return; e.currentTarget.getAnimations().forEach((animation) => { if (!(animation instanceof CSSTransition)) animation.cancel(); }); e.currentTarget.animate([{ scale: 1 }, { scale: .97 }, { scale: 1 }], { duration: 260, easing: "cubic-bezier(.3,.9,.4,1)" }); ring.current?.getAnimations().forEach((animation) => animation.cancel()); @@ -178,6 +192,10 @@ export function ModeSwitcher({ mode, onMode }: { mode: Mode; onMode: (m: Mode) = ); } +export function Banner({ tone = "info", children, action }: { tone?: "info" | "warn" | "err"; children: React.ReactNode; action?: React.ReactNode }) { + return
{children}{action}
; +} + export const useToast = () => Toast.useToastManager(); export function Toasts() { const tr = useT(); diff --git a/tests/e2e/chrome.spec.ts b/tests/e2e/chrome.spec.ts index 4fd8f4e7..0f47d0c8 100644 --- a/tests/e2e/chrome.spec.ts +++ b/tests/e2e/chrome.spec.ts @@ -59,8 +59,8 @@ test("reduced-motion startup inherits themed text immediately", async () => { await page.reload(); await expect(page.locator(".home h1")).toBeVisible(); await expect(page.locator("html")).toHaveAttribute("data-theme", theme); - await expect.poll(() => page.locator(".home h1").evaluate((el) => getComputedStyle(el).color === getComputedStyle(document.body).color)).toBe(true); - expect(await page.locator(".home .composer").evaluate((el) => ({ transition: getComputedStyle(el).transitionDuration, animation: getComputedStyle(el).animationDuration }))).toEqual({ transition: "0s", animation: "0.001s" }); + expect(await page.locator(".home h1").evaluate((el) => getComputedStyle(el).color === getComputedStyle(document.body).color)).toBe(true); + expect(await page.locator(".home .composer").evaluate((el) => ({ transition: getComputedStyle(el).transitionDuration, animation: getComputedStyle(el).animationName }))).toEqual({ transition: "0s", animation: "none" }); } } finally { await app.close(); } }); diff --git a/tests/e2e/kit-motion.spec.ts b/tests/e2e/kit-motion.spec.ts new file mode 100644 index 00000000..d194db3e --- /dev/null +++ b/tests/e2e/kit-motion.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from "@playwright/test"; +import { launch } from "./fixtures"; + +for (const width of [960, 1440]) for (const theme of ["light", "dark"]) test(`kit motion and focus - ${width} ${theme}`, async () => { + const { app, page } = await launch({ hash: `#/home?theme=${theme}`, env: { CORTEX_CATALOG_URL: "data:application/json,{}" } }); + const observations: unknown[] = []; + const capture = async (name: string) => { + await page.evaluate(() => document.fonts.ready); + const path = test.info().outputPath(`${name}-${width}-${theme}.png`); + await page.screenshot({ path }); + await test.info().attach(name, { path, contentType: "image/png" }); + }; + try { + await app.evaluate(({ BrowserWindow }, width) => BrowserWindow.getAllWindows()[0].setSize(width, width === 960 ? 640 : 900), width); + await expect(page.locator(".home")).toBeVisible(); + const tokens = await page.evaluate(() => ["fast", "base", "enter", "exit"].map((name) => { + const value = getComputedStyle(document.documentElement).getPropertyValue(`--motion-${name}`).trim(); + return parseFloat(value) * (value.endsWith("ms") ? 1 : 1000); + })); + expect(tokens).toEqual([120, 220, 320, 180]); + observations.push({ scenario: "DSK-14-tokens", tokens }); + const focusControls = async () => { + await page.keyboard.press("Tab"); + const controls = page.locator(".ibtn, .rail-btn, .theme button, .mode-trigger, .row, .seg-tab, .progress-card, .composer textarea, .composer .model, .send, .pg-nav-i, .pg-theme, .switch"); + for (let index = 0; index < await controls.count(); index++) { + const control = controls.nth(index); + if (!await control.isVisible() || !await control.isEnabled()) continue; + await control.focus(); + const focus = await control.evaluate((element) => { + const style = getComputedStyle(element); + return { name: element.getAttribute("aria-label") ?? element.getAttribute("class"), visible: element.matches(":focus-visible"), width: style.outlineWidth, offset: style.outlineOffset, style: style.outlineStyle }; + }); + expect(focus.visible, focus.name ?? "control").toBe(true); + expect([focus.width, focus.offset, focus.style], focus.name ?? "control").toEqual(["2px", "2px", "solid"]); + observations.push({ scenario: "DSK-14-focus", focus }); + } + }; + await focusControls(); + const rail = page.locator(".rail-btn").first(); + await rail.focus(); + await capture("home-focus"); + await rail.hover(); + await page.mouse.down(); + try { + const pressed = await rail.evaluate(async (element) => { + await Promise.all(element.getAnimations().map((animation) => animation.finished)); + return { active: element.matches(":active"), transform: getComputedStyle(element).transform, opacity: getComputedStyle(element).opacity }; + }); + expect(pressed).toEqual({ active: true, transform: "matrix(0.97, 0, 0, 0.97, 0, 0)", opacity: "0.9" }); + observations.push({ scenario: "DSK-14-press", pressed }); + await capture("rail-pressed"); + } finally { await page.mouse.up(); } + const appearance = async () => { + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByTestId("settings-nav-appearance").click(); + await expect(page.locator(".switch").first()).toBeVisible(); + }; + await appearance(); + await focusControls(); + const toggle = page.locator('.switch[aria-label="Reduce motion"]'); + await page.evaluate(() => { + (window as unknown as { motionChanged: Promise }).motionChanged = new Promise((resolve, reject) => { + const observer = new MutationObserver(() => { + if (!document.documentElement.hasAttribute("data-reduce-motion")) return; + clearTimeout(deadline); observer.disconnect(); resolve(); + }); + const deadline = setTimeout(() => { observer.disconnect(); reject(new Error("Reduced-motion attribute was not applied")); }, 5000); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-reduce-motion"] }); + }); + }); + await toggle.click(); + await page.evaluate(() => (window as unknown as { motionChanged: Promise }).motionChanged); + await expect(toggle).toHaveAttribute("aria-checked", "true"); + expect(await page.evaluate(() => localStorage.getItem("cortex.pref.appearance.reduceMotion"))).toBe("true"); + await capture("settings-reduced-motion"); + await rail.click(); + await expect(page.locator(".home")).toBeVisible(); + const reduced = await page.locator(".home h1, .home .composer, .progress-card circle.val, .sb-scroll").evaluateAll((elements) => elements.map((element) => ({ name: element.getAttribute("class"), animation: getComputedStyle(element).animationName }))); + expect(reduced).toHaveLength(4); + expect(reduced.every((value) => value.animation === "none")).toBe(true); + observations.push({ scenario: "DSK-15-reduced", reduced }); + await capture("home-reduced-motion"); + await page.reload(); + await expect(page.locator(".home")).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.hasAttribute("data-reduce-motion"))).toBe(true); + await appearance(); + await expect(toggle).toHaveAttribute("aria-checked", "true"); + await toggle.click(); + expect(await page.evaluate(() => document.documentElement.hasAttribute("data-reduce-motion"))).toBe(false); + await page.emulateMedia({ reducedMotion: "reduce" }); + await rail.click(); + await expect(page.locator(".home")).toBeVisible(); + expect(await page.locator(".home h1").evaluate((element) => getComputedStyle(element).animationName)).toBe("none"); + observations.push({ scenario: "DSK-15-OS-preference", animation: "none" }); + } finally { + await test.info().attach("kit-motion-observations", { body: JSON.stringify(observations, null, 2), contentType: "application/json" }); + await app.close(); + } +}); From befdbf5681a1ec8d5998edd425c882bff72c3eef Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:46:24 +0000 Subject: [PATCH 2/3] test: await Home controls before motion assertions --- tests/e2e/kit-motion.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/e2e/kit-motion.spec.ts b/tests/e2e/kit-motion.spec.ts index d194db3e..8655c612 100644 --- a/tests/e2e/kit-motion.spec.ts +++ b/tests/e2e/kit-motion.spec.ts @@ -73,8 +73,18 @@ for (const width of [960, 1440]) for (const theme of ["light", "dark"]) test(`ki await expect(toggle).toHaveAttribute("aria-checked", "true"); expect(await page.evaluate(() => localStorage.getItem("cortex.pref.appearance.reduceMotion"))).toBe("true"); await capture("settings-reduced-motion"); + await page.evaluate(() => { + (window as unknown as { homeMounted: Promise }).homeMounted = new Promise((resolve, reject) => { + const observer = new MutationObserver(() => { + if (!document.querySelector(".home h1") || !document.querySelector(".home .composer")) return; + clearTimeout(deadline); observer.disconnect(); resolve(); + }); + const deadline = setTimeout(() => { observer.disconnect(); reject(new Error("Home controls did not mount")); }, 5000); + observer.observe(document.getElementById("root")!, { childList: true, subtree: true }); + }); + }); await rail.click(); - await expect(page.locator(".home")).toBeVisible(); + await page.evaluate(() => (window as unknown as { homeMounted: Promise }).homeMounted); const reduced = await page.locator(".home h1, .home .composer, .progress-card circle.val, .sb-scroll").evaluateAll((elements) => elements.map((element) => ({ name: element.getAttribute("class"), animation: getComputedStyle(element).animationName }))); expect(reduced).toHaveLength(4); expect(reduced.every((value) => value.animation === "none")).toBe(true); From 5fc8d22618cfba6b6de580fcd02327857188d237 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:23:22 +0000 Subject: [PATCH 3/3] fix: respect OS reduced motion during rail press --- packages/app/src/kit/styles.css | 2 +- tests/e2e/kit-motion.spec.ts | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/app/src/kit/styles.css b/packages/app/src/kit/styles.css index 88a104e4..36b45108 100644 --- a/packages/app/src/kit/styles.css +++ b/packages/app/src/kit/styles.css @@ -624,5 +624,5 @@ html[data-reduce-motion] :is(.thinking, .shimmer span) { color: var(--t2) !impor html[data-reduce-motion]::view-transition-group(*), html[data-reduce-motion]::view-transition-old(*), html[data-reduce-motion]::view-transition-new(*) { animation: none !important; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; } - .rail-btn:active { transform: none; } + .rail-btn:active:not(:disabled) { transform: none; } } diff --git a/tests/e2e/kit-motion.spec.ts b/tests/e2e/kit-motion.spec.ts index 8655c612..a035db05 100644 --- a/tests/e2e/kit-motion.spec.ts +++ b/tests/e2e/kit-motion.spec.ts @@ -98,10 +98,31 @@ for (const width of [960, 1440]) for (const theme of ["light", "dark"]) test(`ki await toggle.click(); expect(await page.evaluate(() => document.documentElement.hasAttribute("data-reduce-motion"))).toBe(false); await page.emulateMedia({ reducedMotion: "reduce" }); + await page.evaluate(() => { + (window as unknown as { homeMounted: Promise }).homeMounted = new Promise((resolve, reject) => { + const observer = new MutationObserver(() => { + if (!document.querySelector(".home h1") || !document.querySelector(".home .composer")) return; + clearTimeout(deadline); observer.disconnect(); resolve(); + }); + const deadline = setTimeout(() => { observer.disconnect(); reject(new Error("Home controls did not mount")); }, 5000); + observer.observe(document.getElementById("root")!, { childList: true, subtree: true }); + }); + }); await rail.click(); - await expect(page.locator(".home")).toBeVisible(); + await page.evaluate(() => (window as unknown as { homeMounted: Promise }).homeMounted); expect(await page.locator(".home h1").evaluate((element) => getComputedStyle(element).animationName)).toBe("none"); observations.push({ scenario: "DSK-15-OS-preference", animation: "none" }); + await rail.hover(); + await page.mouse.down(); + try { + const pressed = await rail.evaluate(async (element) => { + await Promise.all(element.getAnimations().map((animation) => animation.finished)); + return { active: element.matches(":active"), transform: getComputedStyle(element).transform }; + }); + expect(pressed).toEqual({ active: true, transform: "none" }); + observations.push({ scenario: "DSK-15-OS-only-press", pressed }); + await capture("rail-pressed-OS-reduced-motion"); + } finally { await page.mouse.up(); } } finally { await test.info().attach("kit-motion-observations", { body: JSON.stringify(observations, null, 2), contentType: "application/json" }); await app.close();