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
9 changes: 7 additions & 2 deletions schemas/motion-scale/motion-scale.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
{
"$comment": "The one motion duration scale of cmux NEXT (Leo 2026-10-20: \"animations all around are a bit slow\"). Every timed fade and every structural move ends within one of these steps; anything entering eases out. scripts/cmux-next/gen-motion-scale.py writes the Swift (MotionScale.generated.swift), CSS (--cmux-motion-<step> in webviews/src/pages/shared/desktop.css) and TypeScript (webviews/src/motion/scale.generated.ts) copies. Loops (spinners, breathing) and hover-intent delays are not transitions and stay off the scale.",
"$comment": "The one motion duration scale of cmux NEXT (Leo 2026-10-20: \"animations all around are a bit slow\"). Every timed fade and every structural move ends within one of these steps; anything entering eases out, and a selection that travels may ride the one spring (a CSS linear() easing; the native side keeps its own spring physics, so the Swift copy has no spring). scripts/cmux-next/gen-motion-scale.py writes the Swift (MotionScale.generated.swift), CSS (--cmux-motion-<step> in webviews/src/pages/shared/desktop.css) and TypeScript (webviews/src/motion/scale.generated.ts) copies. Loops (spinners, breathing) and hover-intent delays are not transitions and stay off the scale.",
"steps": [
{ "name": "quick", "ms": 80, "for": "hover and pressed fills, focus rings, anything leaving (a popup, a notice, a row)" },
{ "name": "micro", "ms": 120, "for": "small things arriving: a fade in, a popup opening, a chip, a tooltip" },
{ "name": "standard", "ms": 180, "for": "structural changes: a row or card moving, a fold opening, a scrim" },
{ "name": "large", "ms": 240, "for": "the largest moves, and the ceiling: a split, a zoom, a window closing, the launch mark" }
],
"easeOut": [0, 0, 0.58, 1]
"easeOut": [0, 0, 0.58, 1],
"spring": {
"step": "large",
"for": "a selection or view that travels to where it lands and settles with a touch of give (a sliding tab indicator, the chat switch); run over its step (large, 240 ms) it is visibly settled by 58% (about 140 ms) and at rest by the end",
"points": [[0, 0], [0.21, 6], [0.53, 14], [0.78, 24], [0.92, 34], [0.985, 46], [1.006, 58], [1.004, 72], [1, 100]]
}
}
27 changes: 26 additions & 1 deletion scripts/cmux-next/gen-motion-scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,23 @@ def load():
assert set(step) == {"name", "ms", "for"} and step["name"].isidentifier(), step
ease = data["easeOut"]
assert len(ease) == 4 and all(0 <= value <= 1 for value in ease), ease
spring = data["spring"]
assert set(spring) == {"step", "for", "points"}, spring
assert spring["step"] in [step["name"] for step in data["steps"]], "the spring runs over one of the steps"
points = spring["points"]
stops = [at for _, at in points]
assert points[0] == [0, 0] and points[-1] == [1, 100], "the spring starts at rest and ends at rest"
assert stops == sorted(set(stops)), "the spring's stops run forward"
return data


def spring_linear(data):
"""The spring as a CSS `linear()` easing: `linear(0, 0.21 6%, ..., 1)`."""
points = data["spring"]["points"]
inner = ["%g %g%%" % (value, at) for value, at in points[1:-1]]
return "linear(%s)" % ", ".join(["0"] + inner + ["1"])


def bezier(data):
return "cubic-bezier(%s)" % ", ".join("%g" % value for value in data["easeOut"])

Expand All @@ -46,6 +60,8 @@ def css_block(data):
lines.append(" --cmux-motion-%s: %sms; /* %s */" % (step["name"], step["ms"], step["for"]))
lines.append(" /* Anything entering eases out. */")
lines.append(" --cmux-ease-out: %s;" % bezier(data))
lines.append(" /* A selection that travels settles on the one spring, run over --cmux-motion-%s. */" % data["spring"]["step"])
lines.append(" --cmux-ease-spring: %s;" % spring_linear(data))
lines += ["}", END]
return "\n".join(lines)

Expand Down Expand Up @@ -73,7 +89,16 @@ def ts(data):

/** Anything entering eases out (`--cmux-ease-out`). */
export const MOTION_EASE_OUT = "{ease}";
""".format(src=SOURCE, steps=steps, ease=bezier(data))

/**
* The one spring (`--cmux-ease-spring`): {spring_for}.
*/
export const MOTION_SPRING = "{spring}";

/** The step the spring runs over (`MOTION_MS[MOTION_SPRING_STEP]`). */
export const MOTION_SPRING_STEP: MotionStep = "{spring_step}";
""".format(src=SOURCE, steps=steps, ease=bezier(data), spring=spring_linear(data),
spring_for=data["spring"]["for"], spring_step=data["spring"]["step"])


def swift(data):
Expand Down
284 changes: 284 additions & 0 deletions webviews/bench/motion-tabs.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
// l10n-allow-file: benchmark output, not shipped UI.
// Tab switch cost of the sliding selection indicator (ui/motion/SlidingIndicator.tsx), on the static gallery (as
// model-picker-open.bench.ts builds it), in Chromium and WebKit. Per implementation:
// response real mouse press and release on the next tab until the first animation frame in which that tab
// is marked selected (aria-selected / aria-pressed): that frame paints the new selection.
// shift_frames frames (of FOLLOW after that one) in which an item, the tab bar or the element after it moved or
// resized by more than half a px. The highlight itself is excluded: it travels by design, on
// transform. 0 means nothing around the tabs moved.
// cls Chromium layout-shift entries from the press to the end of FOLLOW frames.
// settle ms from the press until no animation runs in the document (the slide has ended).
// idle_* IDLE ms after the last switch settled: wakeups (bench/stability.ts), and Chromium renderer task ms.
// Implementations: the gallery's `today` (each item fills itself at once, the base behavior) and `pill-spring`
// (the picked indicator), and the ACP inspector's Wire / Session switch in the agent pane (its real surface).
// bun bench/motion-tabs.bench.ts [contexts=5]
/// <reference types="bun" />
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { chromium, webkit, type Browser, type Page } from "playwright";
import { perfRow, TARGET_MS } from "./perfRow";
import { addStabilityProbe, cpuSession, measureIdle } from "./stability";

const CONTEXTS = Number(process.argv[2] ?? 5);
const SWITCHES = 6;
const FOLLOW = 30;
const IDLE = 2000;

type Impl = {
name: string;
entry: string;
variant: string;
/** The items, in order; the bench switches to the next one each time. */
items: string;
/** The elements whose boxes must not move. */
still: string;
open?: (page: Page) => Promise<void>;
};

const IMPLS: readonly Impl[] = [
{
name: "today",
entry: "ui.motion-sliding-indicator",
variant: "today",
items: ".motion-demo-tabs .motion-demo-tab",
still: ".motion-demo-tabs, .motion-demo-tab, .motion-demo-segmented, .motion-demo-receipt",
},
{
name: "pill_spring",
entry: "ui.motion-sliding-indicator",
variant: "pill-spring",
items: ".motion-demo-tabs .motion-demo-tab",
still: ".motion-demo-tabs, .motion-demo-tab, .motion-demo-segmented, .motion-demo-receipt",
},
{
name: "inspector",
entry: "agent-pane.inspector",
variant: "chat-menu",
items: ".acpmux-inspector-views .acpmux-diff-tool",
still:
".acpmux-inspector-views, .acpmux-inspector-views .acpmux-diff-tool, .acpmux-diff-header strong, .acpmux-inspector-state",
open: async (page) => {
// The variant's play may have opened the chat menu already; open it if not.
const item = page.getByRole("menuitem", { name: "ACP inspector" });
if (!(await item.isVisible().catch(() => false)))
await page.getByRole("button", { name: "Chat actions" }).click();
await item.click();
await page.waitForSelector(".acpmux-inspector-views .acpmux-diff-tool");
},
},
];

/** In-page: arm a press-to-paint probe for item `index`. Resolves window.__mtSwitch. */
function armSwitch(args: { items: string; still: string; index: number; follow: number }) {
const w = window as unknown as Record<string, unknown>;
const shifts: number[] = [];
let observer: PerformanceObserver | undefined;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) shifts.push((entry as unknown as { value: number }).value);
});
observer.observe({ type: "layout-shift", buffered: false });
} catch {
observer = undefined;
}
const boxes = () =>
[...document.querySelectorAll<HTMLElement>(args.still)].flatMap((el) => {
const r = el.getBoundingClientRect();
return [r.x, r.y, r.width, r.height];
});
const selected = () => {
const item = document.querySelectorAll<HTMLElement>(args.items)[args.index];
return item?.getAttribute("aria-selected") === "true" || item?.getAttribute("aria-pressed") === "true";
};
const running = () => document.getAnimations().some((animation) => animation.playState === "running");
w.__mtSwitch = new Promise((resolve) => {
let start = 0;
document.addEventListener("pointerdown", () => (start = performance.now()), { capture: true, once: true });
let first = 0;
let before = boxes();
let moved = 0;
let frames = 0;
let settle = 0;
const tick = () => {
if (start && !first && selected()) {
first = performance.now();
before = boxes();
} else if (first) {
frames += 1;
const now = boxes();
if (now.length !== before.length || now.some((value, at) => Math.abs(value - before[at]!) > 0.5)) moved += 1;
before = now;
if (!settle && !running()) settle = performance.now();
if (frames >= args.follow && settle) {
observer?.disconnect();
resolve({ ms: first - start, moved, cls: shifts.reduce((a, b) => a + b, 0), settle: settle - start });
return;
}
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}

const pct = (values: readonly number[], p: number) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p + 0.5))]!;
};

const webviews = path.resolve(import.meta.dir, "..");
const releases: (() => unknown)[] = [];
const release = async () => {
for (const stop of [...releases].reverse()) {
try {
await stop();
} catch {
// Release the rest.
}
}
};

type Sample = { ms: number[]; moved: number[]; cls: number[]; settle: number[] };
type Switched = { ms: number; moved: number; cls: number; settle: number };

async function switchTo(page: Page, impl: Impl, index: number): Promise<Switched> {
await page.evaluate(armSwitch, { items: impl.items, still: impl.still, index, follow: FOLLOW });
const box = (await page.locator(impl.items).nth(index).boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.up();
const got = await Promise.race([
page.evaluate(() => (window as unknown as { __mtSwitch: Promise<Switched> }).__mtSwitch),
new Promise<null>((r) => setTimeout(() => r(null), 5000)),
]);
if (!got) throw new Error(`${impl.name}: item ${index} never painted selected`);
return got;
}

async function run(browser: Browser, isChromium: boolean, base: string, impl: Impl) {
const sample: Sample = { ms: [], moved: [], cls: [], settle: [] };
let idle: { wakeups: number; cpuMs: number | null; sources: string[] } | undefined;
for (let c = 0; c < CONTEXTS; c++) {
const context = await browser.newContext({ viewport: { width: 1200, height: 800 }, colorScheme: "dark" });
await addStabilityProbe(context, { boxes: false });
const page = await context.newPage();
const cdp = await cpuSession(page, isChromium);
try {
await page.goto(`${base}/frame.html?entry=${impl.entry}&variant=${impl.variant}&colorScheme=dark&width=normal`, {
waitUntil: "domcontentloaded",
});
await page.waitForFunction(() => ["1", "error"].includes(document.documentElement.dataset.galleryReady ?? ""), {
timeout: 90_000,
polling: 100,
});
if ((await page.evaluate(() => document.documentElement.dataset.galleryReady)) !== "1")
throw new Error(`${impl.entry}#${impl.variant}: the stage failed`);
if (impl.open) await impl.open(page);
await page.waitForTimeout(500);
const count = await page.locator(impl.items).count();
if (count < 2) throw new Error(`${impl.name}: ${count} items`);
for (let s = 1; s <= SWITCHES; s++) {
const got = await switchTo(page, impl, s % count);
sample.ms.push(got.ms);
sample.moved.push(got.moved);
sample.cls.push(got.cls);
sample.settle.push(got.settle);
await page.waitForTimeout(150);
}
if (c === CONTEXTS - 1) {
// The last slide ended: one idle window with the tabs still.
await page.mouse.move(2, 2);
await page.waitForTimeout(300);
const [quiet] = await measureIdle([{ page, cdp }], IDLE);
idle = { wakeups: quiet!.wakeups, cpuMs: quiet!.cpuMs, sources: quiet!.sources };
}
} finally {
await context.close();
}
}
return { sample, idle };
}

try {
const out = fs.mkdtempSync(path.join(process.env.TMPDIR ?? os.tmpdir(), "motion-tabs-gallery-"));
releases.push(() => fs.rmSync(out, { recursive: true, force: true }));
const strings = Bun.spawnSync(["node", "scripts/pages/gen-strings.mjs"], { cwd: webviews, stdout: "ignore" });
if (strings.exitCode !== 0) throw new Error(`gen-strings exited ${strings.exitCode}: ${strings.stderr.toString()}`);
process.env.CMUX_GALLERY_OUT_DIR = out;
const { build } = await import("vite");
await build({ root: webviews, configFile: path.join(webviews, "vite.config.gallery.ts"), logLevel: "error" });
const statics = Bun.serve({
port: 0,
hostname: "127.0.0.1",
async fetch(request) {
const pathname = decodeURIComponent(new URL(request.url).pathname);
const file = Bun.file(path.join(out, pathname === "/" ? "index.html" : pathname));
return (await file.exists()) ? new Response(file) : new Response("not found", { status: 404 });
},
});
releases.push(() => statics.stop(true));
const base = `http://127.0.0.1:${statics.port}`;
let failed = 0;
for (const [engine, type] of [
["chromium", chromium],
["webkit", webkit],
] as const) {
const isChromium = engine === "chromium";
const browser = await type.launch({ headless: true });
releases.push(() => browser.close());
console.log(`motion-tabs: ${engine}, ${CONTEXTS} contexts x ${SWITCHES} switches`);
console.log("impl | response p50/p95 | shift frames max | cls max | settle p50/p95 | idle wakeups | cpu ms");
for (const impl of IMPLS) {
try {
const { sample, idle } = await run(browser, isChromium, base, impl);
const name = `motion_tabs.${impl.name}${isChromium ? "" : ".webkit"}`;
const moved = Math.max(...sample.moved);
const cls = Math.max(...sample.cls);
console.log(
`${impl.name} | ${pct(sample.ms, 0.5).toFixed(1)}/${pct(sample.ms, 0.95).toFixed(1)} | ${moved} | ` +
`${isChromium ? cls.toFixed(4) : "-"} | ${pct(sample.settle, 0.5).toFixed(1)}/${pct(sample.settle, 0.95).toFixed(1)} | ` +
`${idle?.wakeups} | ${idle?.cpuMs === null || idle?.cpuMs === undefined ? "-" : idle.cpuMs.toFixed(1)}`,
);
for (const source of idle?.sources.slice(0, 4) ?? []) console.log(` woke: ${source}`);
perfRow({
name: `${name}.response`,
p50: pct(sample.ms, 0.5),
p95: pct(sample.ms, 0.95),
target: TARGET_MS.response,
});
perfRow({
name: `${name}.shift_frames`,
p50: pct(sample.moved, 0.5),
p95: moved,
unit: "count",
target: 0,
floor: 0.5,
});
if (isChromium)
perfRow({ name: `${name}.cls`, p50: pct(sample.cls, 0.5), p95: cls, unit: "score", target: 0, floor: 0.001 });
perfRow({ name: `${name}.settle`, p50: pct(sample.settle, 0.5), p95: pct(sample.settle, 0.95), target: 280 }); // the 240 ms slide plus the frame that paints the click
if (idle) {
perfRow({
name: `${name}.idle_wakeups`,
p50: idle.wakeups,
p95: idle.wakeups,
unit: "count",
target: 0,
floor: 0.5,
});
if (idle.cpuMs !== null)
perfRow({ name: `${name}.idle_cpu_ms`, p50: idle.cpuMs, p95: idle.cpuMs, target: 5, floor: 2 });
}
} catch (error) {
failed += 1;
console.log(
`motion-tabs: ${engine} ${impl.name} FAILED: ${error instanceof Error ? error.stack : String(error)}`,
);
}
}
}
if (failed) process.exitCode = 1;
} finally {
await release();
}
9 changes: 6 additions & 3 deletions webviews/src/agent-session/acpmux/Inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { EventRecord } from "./direct";
import { useT, type Translate, translate } from "./i18n";
import type { AcpmuxSnapshot } from "./model";
import { acpWire, type AcpWireLog, type WireEntry } from "./wire";
import { SlidingIndicator } from "../../ui/motion/SlidingIndicator";
import "./Inspector.css";

/** Rows rendered per view; the newest are kept. */
Expand Down Expand Up @@ -216,18 +217,20 @@ export function Inspector({
‹
</button>
<strong>{t("inspector.title")}</strong>
<div className="acpmux-diff-tools acpmux-inspector-views" aria-label={t("inspector.view")}>
<div className="acpmux-diff-tools acpmux-inspector-views ui-slide-host" aria-label={t("inspector.view")}>
{/* The pressed plate travels between Wire and Session (ui/motion, transform only). */}
<SlidingIndicator selected={view} look="plate" />
<button
type="button"
className="acpmux-diff-tool"
className="acpmux-diff-tool ui-slide-item"
aria-pressed={view === "wire"}
onClick={() => setView("wire")}
>
{t("inspector.wire")}
</button>
<button
type="button"
className="acpmux-diff-tool"
className="acpmux-diff-tool ui-slide-item"
aria-pressed={view === "session"}
onClick={() => setView("session")}
>
Expand Down
Loading
Loading