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
35 changes: 35 additions & 0 deletions packages/app/src/kit/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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:not(:disabled) { transform: none; }
}
26 changes: 22 additions & 4 deletions packages/app/src/kit/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }) {
Expand Down Expand Up @@ -69,7 +80,7 @@ export function Segmented({ items, value: external, onChange, resetKey }: { item
const to = [...l.querySelectorAll<HTMLElement>(".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;
Expand All @@ -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);
}}>
<Tabs.List className="seg" ref={list}>
Expand All @@ -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 <BSwitch.Root className="switch" {...p}><BSwitch.Thumb className="switch-thumb" /></BSwitch.Root>;
return <BSwitch.Root className="switch" {...p} onCheckedChange={(checked) => {
p.onCheckedChange?.(checked);
syncReducedMotion();
}}><BSwitch.Thumb className="switch-thumb" /></BSwitch.Root>;
}

export function ProgressCard({ label, done, total, onClick }: { label: string; done: number; total: number; onClick?: () => void }) {
Expand Down Expand Up @@ -148,7 +162,7 @@ export function ModeSwitcher({ mode, onMode }: { mode: Mode; onMode: (m: Mode) =
return (
<Menu.Root>
<Menu.Trigger className="mode-trigger" onPointerDown={(e) => {
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());
Expand Down Expand Up @@ -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 <div className={`banner ${tone}`} role={tone === "err" ? "alert" : "status"}><span className="grow">{children}</span>{action}</div>;
}

export const useToast = () => Toast.useToastManager();
export function Toasts() {
const tr = useT();
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/chrome.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
});
130 changes: 130 additions & 0 deletions tests/e2e/kit-motion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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<void> }).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<void> }).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 page.evaluate(() => {
(window as unknown as { homeMounted: Promise<void> }).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 page.evaluate(() => (window as unknown as { homeMounted: Promise<void> }).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);
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 page.evaluate(() => {
(window as unknown as { homeMounted: Promise<void> }).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 page.evaluate(() => (window as unknown as { homeMounted: Promise<void> }).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();
}
});
Loading