From ee41b62b5601a4b6983515df4c7bee54d7791f7b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 03:31:04 -0800 Subject: [PATCH 1/6] ui/motion: motion tokens, sliding indicator, view swap, hover and depth primitives The motion scale gains the one spring (chatSwitch's SWITCH_SPRING) as a CSS linear() token, --cmux-ease-spring and MOTION_SPRING, run over the large step. ui/motion adds four primitives with gallery variants: SlidingIndicator (a selection highlight that travels on transform), ViewSwap (an entrance on swapped content that never delays it), hover and reveal utilities (fill 80 ms, reveal 120 in / 80 out, no press), and DepthLayer (washes that lean toward the pointer, or drift once). The ACP inspector's Wire / Session switch uses the indicator. bench/motion-tabs.bench.ts measures response, shift, settle and idle. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa --- schemas/motion-scale/motion-scale.json | 9 +- scripts/cmux-next/gen-motion-scale.py | 27 +- webviews/bench/motion-tabs.bench.ts | 284 ++++++++++++++++++ .../src/agent-session/acpmux/Inspector.tsx | 9 +- .../src/agent-session/acpmux/chatSwitch.ts | 6 +- webviews/src/motion/scale.generated.ts | 8 + webviews/src/pages/shared/desktop.css | 2 + webviews/src/ui/motion/DepthLayer.gallery.tsx | 22 ++ webviews/src/ui/motion/DepthLayer.tsx | 93 ++++++ .../src/ui/motion/HoverFeedback.gallery.tsx | 32 ++ webviews/src/ui/motion/MotionDemos.tsx | 178 +++++++++++ .../ui/motion/SlidingIndicator.gallery.tsx | 44 +++ webviews/src/ui/motion/SlidingIndicator.tsx | 170 +++++++++++ webviews/src/ui/motion/ViewSwap.gallery.tsx | 45 +++ webviews/src/ui/motion/ViewSwap.tsx | 125 ++++++++ webviews/src/ui/motion/motion.css | 201 +++++++++++++ webviews/src/ui/motion/motion.gallery.css | 165 ++++++++++ webviews/src/ui/motion/motion.test.tsx | 185 ++++++++++++ webviews/src/ui/motion/motionMode.ts | 22 ++ webviews/test/motion-scale.test.ts | 14 +- webviews/test/motion-scale.ts | 1 + 21 files changed, 1632 insertions(+), 10 deletions(-) create mode 100644 webviews/bench/motion-tabs.bench.ts create mode 100644 webviews/src/ui/motion/DepthLayer.gallery.tsx create mode 100644 webviews/src/ui/motion/DepthLayer.tsx create mode 100644 webviews/src/ui/motion/HoverFeedback.gallery.tsx create mode 100644 webviews/src/ui/motion/MotionDemos.tsx create mode 100644 webviews/src/ui/motion/SlidingIndicator.gallery.tsx create mode 100644 webviews/src/ui/motion/SlidingIndicator.tsx create mode 100644 webviews/src/ui/motion/ViewSwap.gallery.tsx create mode 100644 webviews/src/ui/motion/ViewSwap.tsx create mode 100644 webviews/src/ui/motion/motion.css create mode 100644 webviews/src/ui/motion/motion.gallery.css create mode 100644 webviews/src/ui/motion/motion.test.tsx create mode 100644 webviews/src/ui/motion/motionMode.ts diff --git a/schemas/motion-scale/motion-scale.json b/schemas/motion-scale/motion-scale.json index b07b06814303..1bf7ee4f469c 100644 --- a/schemas/motion-scale/motion-scale.json +++ b/schemas/motion-scale/motion-scale.json @@ -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- 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- 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]] + } } diff --git a/scripts/cmux-next/gen-motion-scale.py b/scripts/cmux-next/gen-motion-scale.py index ef81d6061920..b3c4363a850f 100755 --- a/scripts/cmux-next/gen-motion-scale.py +++ b/scripts/cmux-next/gen-motion-scale.py @@ -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"]) @@ -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) @@ -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): diff --git a/webviews/bench/motion-tabs.bench.ts b/webviews/bench/motion-tabs.bench.ts new file mode 100644 index 000000000000..bad4a3c0d5b3 --- /dev/null +++ b/webviews/bench/motion-tabs.bench.ts @@ -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] +/// +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; +}; + +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; + 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(args.still)].flatMap((el) => { + const r = el.getBoundingClientRect(); + return [r.x, r.y, r.width, r.height]; + }); + const selected = () => { + const item = document.querySelectorAll(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 { + 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 }).__mtSwitch), + new Promise((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(); +} diff --git a/webviews/src/agent-session/acpmux/Inspector.tsx b/webviews/src/agent-session/acpmux/Inspector.tsx index 1200eeeff432..cef28faf0067 100644 --- a/webviews/src/agent-session/acpmux/Inspector.tsx +++ b/webviews/src/agent-session/acpmux/Inspector.tsx @@ -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. */ @@ -216,10 +217,12 @@ export function Inspector({ ‹ {t("inspector.title")} -
+
+ {/* The pressed plate travels between Wire and Session (ui/motion, transform only). */} + + ))} +
+ ); +} + +const PANELS: Record<(typeof TABS)[number], [string, string][]> = { + Overview: [ + ["Level", "60"], + ["Rank", "S"], + ["Faction", "Section 6"], + ], + Skills: [ + ["Basic", "12 / 12"], + ["Dodge", "12 / 12"], + ["Chain", "12 / 12"], + ], + Gear: [ + ["Engine", "Sunbringer"], + ["Disc 1", "+15"], + ["Disc 2", "+15"], + ], + Stats: [ + ["HP", "5,736"], + ["ATK", "441"], + ["Crit rate", "51.2%"], + ], +}; + +export type IndicatorDemoProps = { look: IndicatorLook | "today"; travel?: IndicatorTravel }; + +/** A chunky tab bar and a two-way segmented control, both on one indicator look. */ +export function IndicatorDemo({ look, travel = "spring" }: IndicatorDemoProps) { + const [tab, setTab] = useState(0); + const [view, setView] = useState(0); + return ( +
+ +
+ {look !== "today" && ( + + )} + {["Wire", "Session"].map((label, index) => ( + + ))} +
+ + {TABS[tab]} · {view === 0 ? "Wire" : "Session"} + +
+ ); +} + +export type SwapDemoProps = { look: ViewSwapLook }; + +/** The tab bar over a panel of stat cards that swaps with the chosen look. */ +export function SwapDemo({ look }: SwapDemoProps) { + const [tab, setTab] = useState(0); + const name = TABS[tab]!; + return ( +
+ + +
+ {PANELS[name].map(([label, value]) => ( + + ))} +
+
+ + {name} + +
+ ); +} + +export type HoverDemoProps = { timing: "blink" | "scale" | "slow" | "press-dip" }; + +/** Rows with a hover fill on their own button and an ellipsis revealed on the row's hover. */ +export function HoverDemo({ timing }: HoverDemoProps) { + const [opened, setOpened] = useState(); + return ( +
+
    + {["Fix the rail", "Session Info tabs", "Motion tokens"].map((label, index) => ( +
  • + {label} + +
  • + ))} +
+ + {opened === undefined ? "Nothing open" : `Menu open on row ${opened + 1}`} + +
+ ); +} + +export type DepthDemoProps = { look: DepthLook }; + +/** A Home-like card with the depth layer behind its content. */ +export function DepthDemo({ look }: DepthDemoProps) { + const surface = useRef(null); + return ( +
+ +
+ Good evening + Three chats are running. Move the pointer over the card. +
+
+ ); +} diff --git a/webviews/src/ui/motion/SlidingIndicator.gallery.tsx b/webviews/src/ui/motion/SlidingIndicator.gallery.tsx new file mode 100644 index 000000000000..10d2ad98ccd3 --- /dev/null +++ b/webviews/src/ui/motion/SlidingIndicator.gallery.tsx @@ -0,0 +1,44 @@ +// l10n-allow-file: gallery labels and actions are fixture content, not shipped UI. +import { componentEntry } from "../../gallery/format"; +import type { Play } from "../../gallery/play"; +import { IndicatorDemo, type IndicatorDemoProps } from "./MotionDemos"; + +const switchTabs: Play = async (ctx) => { + await ctx.click({ role: "tab", name: "Stats" }); + await ctx.waitFor(() => ctx.find({ testId: "motion-demo-receipt" }).textContent?.startsWith("Stats")); + await ctx.click({ role: "tab", name: "Skills" }); + await ctx.click({ role: "button", name: "Session" }); + await ctx.waitFor(() => ctx.find({ testId: "motion-demo-receipt" }).textContent === "Skills · Session"); +}; + +// Picked: pill-spring. The reference (a game character menu, private reference +// zenless-zone-zero/live/motion-sunbringer-menu-animation-sheet) is a chunky pill whose fill slides between tabs; the spring +// lands it fast (settled by ~140 ms) with a touch of give, while the labels change on the click. +export default componentEntry({ + id: "ui.motion-sliding-indicator", + title: "Sliding selection indicator", + area: "UI primitives", + height: 220, + anchors: [{ selector: ".motion-demo-receipt" }], + checks: { + anchorMovePx: { value: 0, reason: "The highlight moves on transform only; nothing around the tabs moves." }, + settleMaxMs: { + value: 300, + reason: "The highlight travels on transform for the 240 ms spring after the click frame.", + }, + }, + covers: ["ui/motion/SlidingIndicator.tsx", "ui/motion/MotionDemos.tsx#IndicatorDemo"], + load: async () => IndicatorDemo, + styles: () => Promise.all([import("./motion.css"), import("./motion.gallery.css")]), + variants: { + "pill-spring": { note: "Picked: chunky pill on the one spring.", props: { look: "pill" }, play: switchTabs }, + "pill-ease": { + note: "The pill on the ease-out at 180 ms.", + props: { look: "pill", travel: "ease" }, + play: switchTabs, + }, + "plate-spring": { note: "The toolbar's quiet plate, travelling.", props: { look: "plate" }, play: switchTabs }, + "underline-spring": { note: "A 2 px underline.", props: { look: "underline" }, play: switchTabs }, + today: { note: "Before: each item fills itself at once.", props: { look: "today" }, play: switchTabs }, + }, +}); diff --git a/webviews/src/ui/motion/SlidingIndicator.tsx b/webviews/src/ui/motion/SlidingIndicator.tsx new file mode 100644 index 000000000000..54c2be68a390 --- /dev/null +++ b/webviews/src/ui/motion/SlidingIndicator.tsx @@ -0,0 +1,170 @@ +// A selection highlight that slides between the items of a tab bar, segmented control or picker +// (Leo, 2026-11-03: "i sent this zzz video i thought was cute and cool and i wanted to have more... +// nice stuff? around the app"). The highlight is one element behind the items: at rest it sits on the +// selected item; when the selection changes it is placed on the new item at once and a Web Animation +// carries it there from where it was drawn, on transform only. +// +// Rules it keeps (leo-workspace design/motion-and-polish.md): the selection changes on the click, and +// the item's own state (aria-selected / aria-pressed, its colour) is the new one in that same frame; +// only the highlight travels. Transform only, so every frame is the compositor's and nothing around it +// moves (no layout shift). The first frame is the final frame: on mount, and when a size changes, the +// highlight is placed with no motion. Interruptible: a change during a slide starts from the drawn +// position. Motion off, a hidden page or a zero motion token: placed at once. Reduce Motion: placed at +// once (the item's own state change is the crossfade). +// +// Stop conditions: no timer and no frame loop. The slide is one finite Web Animation (at most +// --cmux-motion-large, 240 ms) that ends on its own; the next change and unmount cancel it. One +// ResizeObserver on the host and its items re-places the highlight when a size changes (a label, a +// font load); it is disconnected on unmount. +import { type CSSProperties, type RefObject, useLayoutEffect, useRef } from "react"; +import { MOTION_EASE_OUT, MOTION_MS, MOTION_SPRING, MOTION_SPRING_STEP } from "../../motion/scale.generated"; +import { cx } from "../cx"; +import { motionMode } from "./motionMode"; +import "./motion.css"; + +/** How the highlight looks: a filled pill, a quiet plate (the toolbar's pressed fill), or an underline. */ +export type IndicatorLook = "pill" | "plate" | "underline"; +/** How it travels: the one spring, the ease-out at `standard`, or not at all. */ +export type IndicatorTravel = "spring" | "ease" | "none"; + +/** The selected item, by default: the one a tablist, a toolbar or a radio group marks. */ +export const SELECTED_ITEM = '[aria-selected="true"], [aria-pressed="true"], [aria-checked="true"], [data-selected]'; + +type Box = { x: number; y: number; w: number; h: number }; + +const same = (a: Box, b: Box) => + Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.w - b.w) < 0.5 && Math.abs(a.h - b.h) < 0.5; + +/** Where `item` sits in `host`'s padding box, in CSS px (sub-pixel kept). */ +function boxIn(host: HTMLElement, item: HTMLElement): Box { + const outer = host.getBoundingClientRect(); + const inner = item.getBoundingClientRect(); + return { + x: inner.left - outer.left - host.clientLeft + host.scrollLeft, + y: inner.top - outer.top - host.clientTop + host.scrollTop, + w: inner.width, + h: inner.height, + }; +} + +/** Where the highlight is drawn now (mid-slide included), from its own size and computed transform. */ +function drawnBox(mark: HTMLElement, rest: Box): Box { + const matrix = new DOMMatrixReadOnly(getComputedStyle(mark).transform); + return { x: matrix.e, y: matrix.f, w: rest.w * matrix.a, h: rest.h * matrix.d }; +} + +export type SlidingIndicatorOptions = { + /** Changes whenever the selection does (the selected value). The highlight moves when it changes. */ + selected: unknown; + look?: IndicatorLook; + travel?: IndicatorTravel; + /** Finds the selected item inside the host. */ + selector?: string; +}; + +/** + * Places `mark` on the selected item inside its parent (the host) and slides it on each change of + * `selected`. The host must be the highlight's containing block (`.ui-slide-host` makes it one). The + * host is read from the DOM, not a ref: a child's layout effects run before its parent's ref is set. + */ +export function useSlidingIndicator( + mark: RefObject, + { selected, travel = "spring", selector = SELECTED_ITEM }: SlidingIndicatorOptions, +) { + const rest = useRef(null); + const slide = useRef(null); + + // Place it on every selection change, sliding from where it is drawn. + // perf-hot: one read and one write per selection change; no per-frame work. + useLayoutEffect(() => { + const element = mark.current; + const parent = element?.parentElement; + if (!parent || !element) return; + const item = parent.querySelector(selector); + if (!item) { + slide.current?.cancel(); + slide.current = null; + rest.current = null; + element.dataset.empty = ""; + return; + } + const target = boxIn(parent, item); + const was = rest.current; + // Read the drawn position before the running slide is cancelled, so an interrupted slide + // continues from where the eye saw it. + const from = was && slide.current?.playState === "running" ? drawnBox(element, was) : was; + slide.current?.cancel(); + slide.current = null; + rest.current = target; + delete element.dataset.empty; + element.style.width = `${target.w}px`; + element.style.height = `${target.h}px`; + element.style.transform = `translate(${target.x}px, ${target.y}px)`; + if (!from || same(from, target) || travel === "none" || motionMode(element.ownerDocument) !== "full") return; + const scaleX = target.w > 0 ? from.w / target.w : 1; + const scaleY = target.h > 0 ? from.h / target.h : 1; + slide.current = element.animate( + [ + { transform: `translate(${from.x}px, ${from.y}px) scale(${scaleX}, ${scaleY})` }, + { transform: `translate(${target.x}px, ${target.y}px)` }, + ], + travel === "spring" + ? { duration: MOTION_MS[MOTION_SPRING_STEP], easing: MOTION_SPRING } + : { duration: MOTION_MS.standard, easing: MOTION_EASE_OUT }, + ); + slide.current.onfinish = () => { + slide.current = null; + }; + }, [mark, selected, travel, selector]); + + // Re-place it, with no motion, when the host or an item changes size; stop on unmount. + useLayoutEffect(() => { + const element = mark.current; + const parent = element?.parentElement; + if (!parent || !element || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + const item = parent.querySelector(selector); + if (!item || slide.current) return; + const target = boxIn(parent, item); + if (rest.current && same(rest.current, target)) return; + rest.current = target; + element.style.width = `${target.w}px`; + element.style.height = `${target.h}px`; + element.style.transform = `translate(${target.x}px, ${target.y}px)`; + }); + observer.observe(parent); + for (const child of parent.children) if (child !== element) observer.observe(child); + return () => observer.disconnect(); + }, [mark, selector]); + + useLayoutEffect( + () => () => { + slide.current?.cancel(); + slide.current = null; + }, + [], + ); +} + +export type SlidingIndicatorProps = SlidingIndicatorOptions & { + className?: string; + style?: CSSProperties; +}; + +/** + * The highlight element. Render it as the first child of the element holding the items (a tablist, + * a toolbar group); give that host `ui-slide-host` and the items `ui-slide-item`. + */ +export function SlidingIndicator({ className, style, look = "pill", ...options }: SlidingIndicatorProps) { + const mark = useRef(null); + useSlidingIndicator(mark, options); + return ( +