diff --git a/packages/app/e2e/regression/panel-edges.spec.ts b/packages/app/e2e/regression/panel-edges.spec.ts index 064804c7ad5..99f21e261f5 100644 --- a/packages/app/e2e/regression/panel-edges.spec.ts +++ b/packages/app/e2e/regression/panel-edges.spec.ts @@ -32,7 +32,7 @@ async function trace(page: Page, toggle: Locator, before: string, after: string) return frames } -test("the content beside a panel follows the panel's edge on every frame of a toggle", async ({ page }) => { +test("a toggle moves the panel and the content beside it in the same frame, without animating", async ({ page }) => { await mockStressTimeline(page) await installStressSessionTabs(page) await seed(page, { settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } } }) @@ -57,8 +57,8 @@ test("the content beside a panel follows the panel's edge on every frame of a to for (const run of runs) { const frames = await trace(page, run.toggle, run.before, run.after) - // The toggle animates over several frames rather than jumping to its end. - expect(new Set(frames.map((frame) => Math.round(frame.edge))).size).toBeGreaterThan(3) + // The edge jumps straight from where it was to where it lands. + expect(new Set(frames.map((frame) => Math.round(frame.edge))).size).toBeLessThanOrEqual(2) frames.flatMap((frame) => frame.gap ?? []).forEach((gap) => expect(gap).toBeCloseTo(run.gap, 0)) } }) diff --git a/packages/app/e2e/regression/review.spec.ts b/packages/app/e2e/regression/review.spec.ts index c830d6a1d58..9d9f641126a 100644 --- a/packages/app/e2e/regression/review.spec.ts +++ b/packages/app/e2e/regression/review.spec.ts @@ -1304,13 +1304,14 @@ for (const direction of ["ltr", "rtl"] as const) { await expect.poll(() => toggle.boundingBox()).toEqual(closed) await expect(panel).toBeHidden() - // The chat's width springs; once the panel settles open, it fills the rest of the row beside the chat. + // Opened, the panel fills the rest of the row beside the chat; closed, it is gone at once. for (const opened of [true, false]) { await toggle.click() await expect(toggle).toHaveAttribute("aria-expanded", String(opened)) - await expect(panel).toHaveAttribute("aria-hidden", String(!opened)) - if (opened) await expect(() => expectHeaderClearOfToggle(page, toggle)).toPass() + if (!opened) continue + await expect(panel).toHaveAttribute("aria-hidden", "false") + await expect(() => expectHeaderClearOfToggle(page, toggle)).toPass() } await expect(panel).toBeHidden() diff --git a/packages/app/src/runtime/extension/commands.tsx b/packages/app/src/runtime/extension/commands.tsx index fd039e63643..f07a42a68e7 100644 --- a/packages/app/src/runtime/extension/commands.tsx +++ b/packages/app/src/runtime/extension/commands.tsx @@ -21,6 +21,7 @@ export function ExtensionCommands() { id: `${item.extension}.${item.value.id}`, title: item.value.title, description: item.value.description, + keywords: item.value.keywords, category: item.value.group, section: item.value.section, keybind: item.value.bind, diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 8be90ac0db0..6db43d9d60a 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -37,7 +37,6 @@ import { createTimelineQuote, TimelineQuote } from "./timeline/quote" import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region" import { SessionIdentityHeader } from "./session-identity-header" import { SessionReviewToggle } from "./header/session-header-actions" -import { createPanelMotion, panelSprings, replayPanelToggle } from "@/shell/panel-motion" import { createTimelineCache } from "./timeline/cache" export function SessionScreenView(props: { session: SessionModel }) { @@ -115,41 +114,17 @@ function SessionScreenContent(props: { const sideVisible = createMemo(() => isDesktop() && screen.side.open()) - // The chat keeps its open width as the far end of a close's travel. - const chatOpenWidth = createMemo((previous) => (sideVisible() ? screen.panel.width() : previous), "100%") - let chat: HTMLDivElement | undefined - - const sideMotion = createPanelMotion({ - open: sideVisible, - spring: panelSprings.side, - identity: session.layout.tabKey, - // The chat's real width moves the side panel's edge, whose contents keep their open width pinned to it. - paint: (closed) => { - if (!chat) return - const open = chatOpenWidth() - - if (closed === 1) { - chat.style.width = "100%" - - return - } - - chat.style.width = closed === 0 ? open : `calc(${open} + (100% - ${open}) * ${closed})` - }, - onClosed: () => { - setStore("sideRegionPresent", false) - setStore("sideTabsPresent", false) - }, - }) - - replayPanelToggle({ - id: "sidePanel", - group: "Side panel", - toggle: () => session.layout.view().side.toggle(), - spring: panelSprings.side, - }) - - const sideFileTree = createMemo((previous) => (sideVisible() ? screen.files.open() : previous), false) + // A close unmounts the side region; switching to a layout whose side is closed keeps it, hidden. + createEffect( + on( + () => [sideVisible(), session.layout.tabKey()] as const, + ([visible, key], previous) => { + if (visible || !previous || previous[1] !== key) return + setStore("sideRegionPresent", false) + setStore("sideTabsPresent", false) + }, + ), + ) const hideTimelineScrollbar = () => setStore("timelineScrollbarHidden", true) @@ -374,10 +349,10 @@ function SessionScreenContent(props: {
- +
diff --git a/packages/app/src/shell/commands/command.tsx b/packages/app/src/shell/commands/command.tsx index d93693b4ee1..18197ef98ef 100644 --- a/packages/app/src/shell/commands/command.tsx +++ b/packages/app/src/shell/commands/command.tsx @@ -94,6 +94,8 @@ export interface CommandOption { id: string title: string description?: string + /** Extra words the command palette matches, separated by spaces. */ + keywords?: string category?: string /** Section of Settings > Shortcuts. Host commands leave it unset and are placed by id prefix. */ section?: CommandSection diff --git a/packages/app/src/shell/commands/dialog.tsx b/packages/app/src/shell/commands/dialog.tsx index 203415586a6..06d6d2c020e 100644 --- a/packages/app/src/shell/commands/dialog.tsx +++ b/packages/app/src/shell/commands/dialog.tsx @@ -27,7 +27,9 @@ function groups(entries: CommandPaletteEntry[]) { export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: string) { const value = query.toLowerCase() - return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value)) + return [entry.title, entry.description, entry.category, entry.keywords].some((text) => + text?.toLowerCase().includes(value), + ) } export function DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) { diff --git a/packages/app/src/shell/commands/palette.ts b/packages/app/src/shell/commands/palette.ts index 65edd9a892c..4e14cf36e33 100644 --- a/packages/app/src/shell/commands/palette.ts +++ b/packages/app/src/shell/commands/palette.ts @@ -22,6 +22,7 @@ export type CommandPaletteEntry = { type: "command" | "file" | "session" title: string description?: string + keywords?: string keybind?: string category: string option?: CommandOption @@ -215,6 +216,7 @@ export function createCommandPaletteCommandEntry(option: CommandOption, category type: "command", title: option.title, description: option.description, + keywords: option.keywords, keybind: option.keybind, category, option, diff --git a/packages/app/src/shell/panel-motion.ts b/packages/app/src/shell/panel-motion.ts deleted file mode 100644 index 1d143652a81..00000000000 --- a/packages/app/src/shell/panel-motion.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js" -import { frameLoop, prefersReducedMotion, Spring, type DurationOptions } from "@opencode/ui/motion" -import { motionTuning } from "@opencode/ui/motion-tuning" - -const sidebar = motionTuning.spring("sidebar", { label: "Spring", group: "Left sidebar", duration: 0.2, bounce: 0 }) - -const side = motionTuning.spring("sidePanel", { label: "Spring", group: "Side panel", duration: 0.2, bounce: 0 }) - -const linked = motionTuning.toggle("sidePanelLinked", { - label: "Follow the left sidebar's spring", - group: "Side panel", - value: true, -}) - -const keyboardInstant = motionTuning.toggle("keyboardInstant", { - label: "Keyboard toggles are instant", - group: "Global", - value: true, -}) - -export const panelSprings = { - sidebar, - side: () => (linked() ? sidebar() : side()), -} - -/** Adds buttons that play a panel's toggle to its Motion workspace section while the panel is mounted. */ -export function replayPanelToggle(input: { - id: string - group: string - toggle: () => void - spring: () => DurationOptions -}) { - onCleanup( - motionTuning.action({ id: `${input.id}.toggle`, label: "Open / close", group: input.group, run: input.toggle }), - ) - - onCleanup( - motionTuning.action({ - id: `${input.id}.reverse`, - label: "Toggle, reverse mid-flight", - group: input.group, - run: () => { - input.toggle() - setTimeout(input.toggle, input.spring().duration * 350) - }, - }), - ) -} - -// Whether the input that caused a toggle was a key press, so shortcuts can skip the motion. -let keyboard = false - -if (typeof window !== "undefined") { - window.addEventListener("keydown", () => (keyboard = true), true) - window.addEventListener("pointerdown", () => (keyboard = false), true) -} - -/** - * Opens and closes an edge panel on a spring. `paint` receives how far closed the panel is, from 0 (open) to 1 - * (closed), every frame of a flight and whenever a signal it reads changes at rest. It sizes the panel's real - * layout box, so the neighbouring content's edge follows the panel's edge on every frame. `present` stays true until - * a close settles. - */ -export function createPanelMotion(input: { - open: Accessor - spring: () => DurationOptions - paint: (closed: number) => void - /** A change shows the new state without motion, e.g. switching to another session's layout. */ - identity?: Accessor - onClosed?: () => void -}) { - const [present, setPresent] = createSignal(input.open()) - // Percent closed, so the spring's rest threshold stays well under a pixel. - const spring = new Spring(input.open() ? 0 : 100) - let stop: (() => void) | undefined - - const paint = () => input.paint(Math.min(1, Math.max(0, spring.value / 100))) - - const settle = () => { - stop?.() - stop = undefined - - if (input.open()) return - setPresent(false) - input.onClosed?.() - } - - createEffect( - on( - () => [input.open(), input.identity?.()] as const, - ([open, identity], previous) => { - const target = open ? 0 : 100 - - if (open) setPresent(true) - - if (previous === undefined || previous[1] !== identity) { - stop?.() - stop = undefined - spring.snap(target) - paint() - - if (!open) setPresent(false) - - return - } - - if (prefersReducedMotion() || (keyboard && keyboardInstant())) { - spring.snap(target) - paint() - settle() - - return - } - - const tuning = input.spring() - spring.omega = (2 * Math.PI) / tuning.duration - spring.zeta = 1 - tuning.bounce - spring.set(target) - stop ??= frameLoop.add((dt) => { - spring.step(dt) - paint() - - if (!spring.resting) return true - stop = undefined - settle() - - return false - }) - }, - ), - ) - - // Repaints at rest when the open size changes, e.g. a resize drag or a window resize. - createEffect(paint) - - onCleanup(() => stop?.()) - - return { present } -} diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 2fbfc03e2f0..d6c8eb1c133 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -14,7 +14,6 @@ import { ExtensionSlot } from "@/runtime/extension/render" import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { CollapsedSidebarToggle } from "@/shell/sidebar-toggle" -import { createPanelMotion, panelSprings, replayPanelToggle } from "@/shell/panel-motion" export default function Layout(props: ParentProps) { const platform = usePlatform() @@ -27,33 +26,13 @@ export default function Layout(props: ParentProps) { const [state, setState] = createStore<{ tabsWidth: number tabsMount: HTMLElement | undefined - tabsAside: HTMLElement | undefined }>({ tabsWidth: 260, tabsMount: undefined, - tabsAside: undefined, }) const verticalTabs = () => preferences.appearance.tabLayout() !== "horizontal" && !mobile() - const sidebarMotion = createPanelMotion({ - open: preferences.appearance.sidebarOpen, - spring: panelSprings.sidebar, - // The aside's real width moves main's edge with the sidebar's; its contents keep their width, pinned to that edge. - paint: (closed) => { - const aside = state.tabsAside - - if (aside) aside.style.width = `${state.tabsWidth * (1 - closed)}px` - }, - }) - - replayPanelToggle({ - id: "sidebar", - group: "Left sidebar", - toggle: () => preferences.appearance.setSidebarOpen(!preferences.appearance.sidebarOpen()), - spring: panelSprings.sidebar, - }) - command.register("sidebar", () => verticalTabs() ? [ @@ -95,11 +74,14 @@ export default function Layout(props: ParentProps) { id="vertical-tabs-sidebar" data-slot="vertical-tabs-sidebar" data-state={preferences.appearance.sidebarOpen() ? "open" : "closed"} - ref={(element) => setState("tabsAside", element)} class="relative z-10 h-full min-h-0 shrink-0" + style={{ width: preferences.appearance.sidebarOpen() ? `${state.tabsWidth}px` : "0px" }} inert={!preferences.appearance.sidebarOpen()} > -
+
setState("tabsMount", element)} class="absolute inset-y-0 end-0 contain-strict flex min-h-0 flex-col bg-v2-background-bg-deep pe-0.5 ps-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]" diff --git a/packages/app/test-browser/command-palette.test.ts b/packages/app/test-browser/command-palette.test.ts index b420ac17441..86f40f81bb6 100644 --- a/packages/app/test-browser/command-palette.test.ts +++ b/packages/app/test-browser/command-palette.test.ts @@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test" import type { Project } from "@/runtime/server/types" import type { SessionInfo } from "@opencode/client/promise" import { createRoot } from "solid-js" -import { createServerSessionEntries } from "@/shell/commands/palette" +import { createCommandPaletteCommandEntry, createServerSessionEntries } from "@/shell/commands/palette" +import { matchesCommandPaletteEntry } from "@/shell/commands/dialog" import { ServerConnection } from "@/runtime/server/registry" import { getProjectAvatarSource } from "@opencode/ui/project-avatar" @@ -121,3 +122,16 @@ describe("command palette sessions", () => { expect(result).toMatchObject([{ sessionID: exact.id, title: exact.title }]) }) }) + +describe("command palette commands", () => { + test("match their keywords as well as their title", () => { + const entry = createCommandPaletteCommandEntry( + { id: "debug.toggle", title: "Toggle debug bar", keywords: "dev bar developer tools diagnostics" }, + "View", + ) + + expect(matchesCommandPaletteEntry(entry, "dev bar")).toBe(true) + expect(matchesCommandPaletteEntry(entry, "Diagnostics")).toBe(true) + expect(matchesCommandPaletteEntry(entry, "terminal")).toBe(false) + }) +}) diff --git a/packages/app/test-browser/motion-tuning-panel.test.ts b/packages/app/test-browser/motion-tuning-panel.test.ts new file mode 100644 index 00000000000..b5f2a9fa50a --- /dev/null +++ b/packages/app/test-browser/motion-tuning-panel.test.ts @@ -0,0 +1,74 @@ +import { afterEach, expect, it } from "bun:test" +import { motionTuning } from "@opencode/ui/motion-tuning" +import { mountMotionPanel } from "@opencode/ui/motion-tuning-panel" + +const cleanups: (() => void)[] = [] + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup() + document.body.replaceChildren() +}) + +it("offers Snappy, Smooth and Calm for every spring, with one Global card and one card per section", async () => { + const drawer = motionTuning.spring("test.drawer", { + label: "Spring", + group: "Drawer", + presets: { + snappy: { duration: 0.15, bounce: 0 }, + smooth: { duration: 0.25, bounce: 0 }, + calm: { duration: 0.4, bounce: 0 }, + }, + }) + + const image = motionTuning.spring("test.image", { + label: "Spring", + group: "Image open", + presets: { + snappy: { duration: 0.2, bounce: 0 }, + smooth: { duration: 0.3, bounce: 0 }, + calm: { duration: 0.4, bounce: 0 }, + }, + }) + + cleanups.push(motionTuning.action({ id: "test.replay", label: "Replay", group: "Drawer", run: () => {} })) + + const container = document.createElement("div") + document.body.append(container) + cleanups.push(mountMotionPanel(container, { section: "Drawer" })) + + const chip = await waitFor(() => + [...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Smooth"), + ) + + const labels = [...container.querySelectorAll("button")].map((button) => button.textContent?.trim()) + + expect(labels).toContain("Snappy") + expect(labels).toContain("Calm") + expect(labels).not.toContain("default") + + // Global once, then the section's spring with its replay buttons under a sub-heading in the same card. + const shown = (selector: string) => + [...container.querySelectorAll(selector)].flatMap((element) => + element.closest("[hidden]") === null ? [element.textContent?.trim()] : [], + ) + + expect(shown(".knobs-group-label")).toEqual(["Global", "Drawer"]) + expect(shown(".knobs-subhead-label")).toEqual(["Replay"]) + expect(labels).not.toContain("Jump to…") + expect(drawer()).toEqual({ duration: 0.15, bounce: 0 }) + + chip.click() + expect(drawer()).toEqual({ duration: 0.25, bounce: 0 }) + expect(image()).toEqual({ duration: 0.3, bounce: 0 }) +}) + +async function waitFor(find: () => T | undefined) { + for (let i = 0; i < 200; i++) { + const found = find() + + if (found) return found + await new Promise((resolve) => setTimeout(resolve, 10)) + } + + throw new Error("timed out") +} diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts deleted file mode 100644 index 4d7d8626474..00000000000 --- a/packages/app/test-browser/panel-motion.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { afterEach, beforeEach, expect, it, spyOn } from "bun:test" -import { createRoot, createSignal } from "solid-js" -import { createPanelMotion } from "../src/shell/panel-motion" - -let frames: Map - -let id: number - -let time: number - -const cleanups: (() => void)[] = [] - -const originals = { raf: globalThis.requestAnimationFrame, caf: globalThis.cancelAnimationFrame } - -beforeEach(() => { - frames = new Map() - id = 0 - time = 0 - spyOn(performance, "now").mockImplementation(() => time) - globalThis.requestAnimationFrame = (fn: FrameRequestCallback) => { - frames.set(++id, fn) - - return id - } - - globalThis.cancelAnimationFrame = (key: number) => { - frames.delete(key) - } -}) - -afterEach(() => { - for (const cleanup of cleanups.splice(0)) cleanup() - globalThis.requestAnimationFrame = originals.raf - globalThis.cancelAnimationFrame = originals.caf -}) - -function frame() { - time += 1000 / 60 - const next = [...frames.values()] - frames.clear() - - for (const callback of next) callback(time) -} - -function settle() { - for (let i = 0; i < 120 && frames.size; i++) frame() -} - -function setup(initial: boolean) { - const [open, setOpen] = createSignal(initial) - const [identity, setIdentity] = createSignal("a") - const [size, setSize] = createSignal(200) - const closed: number[] = [] - const painted = { width: 0 } - - const motion = createRoot((dispose) => { - cleanups.push(dispose) - - return createPanelMotion({ - open, - spring: () => ({ duration: 0.2, bounce: 0 }), - paint: (shut) => { - painted.width = size() * (1 - shut) - }, - identity, - onClosed: () => closed.push(time), - }) - }) - - return { painted, setOpen, setIdentity, setSize, closed, present: motion.present } -} - -it("sizes a pointer toggle open and closed on a spring, present until the close settles", () => { - window.dispatchEvent(new Event("pointerdown")) - const panel = setup(false) - expect(panel.present()).toBe(false) - expect(panel.painted.width).toBe(0) - - panel.setOpen(true) - expect(panel.present()).toBe(true) - expect(panel.painted.width).toBe(0) - frame() - expect(panel.painted.width).toBeGreaterThan(0) - expect(panel.painted.width).toBeLessThan(200) - settle() - expect(panel.painted.width).toBe(200) - - panel.setOpen(false) - frame() - expect(panel.present()).toBe(true) - expect(panel.closed).toEqual([]) - settle() - expect(panel.painted.width).toBe(0) - expect(panel.present()).toBe(false) - expect(panel.closed).toHaveLength(1) -}) - -it("moves the panel's size a little on every frame, with no jump at the start or settle", () => { - window.dispatchEvent(new Event("pointerdown")) - const panel = setup(false) - panel.setOpen(true) - const widths: number[] = [] - - for (let i = 0; i < 120 && frames.size; i++) { - frame() - widths.push(panel.painted.width) - } - - const steps = widths.map((width, index) => width - (widths[index - 1] ?? 0)) - expect(steps.every((step) => step >= 0)).toBe(true) - expect(Math.max(...steps)).toBeLessThan(60) - // The last moving frame lands within a pixel of rest. - expect(200 - widths.findLast((width) => width < 200)!).toBeLessThan(1) -}) - -it("turns around mid-flight from where it is", () => { - window.dispatchEvent(new Event("pointerdown")) - const panel = setup(false) - panel.setOpen(true) - frame() - frame() - frame() - const before = panel.painted.width - - panel.setOpen(false) - expect(panel.painted.width).toBe(before) - frame() - // Still carrying its opening velocity, it does not jump back closed. - expect(Math.abs(panel.painted.width - before)).toBeLessThan(30) - settle() - expect(panel.present()).toBe(false) - expect(panel.painted.width).toBe(0) -}) - -it("repaints at rest when the open size changes, like a resize drag", () => { - window.dispatchEvent(new Event("pointerdown")) - const panel = setup(true) - expect(panel.painted.width).toBe(200) - - panel.setSize(260) - expect(panel.painted.width).toBe(260) - expect(frames.size).toBe(0) -}) - -it("applies keyboard toggles at once", () => { - window.dispatchEvent(new Event("keydown")) - const panel = setup(true) - - panel.setOpen(false) - expect(panel.painted.width).toBe(0) - expect(panel.present()).toBe(false) - expect(panel.closed).toHaveLength(1) - expect(frames.size).toBe(0) -}) - -it("shows another layout's state without motion or a close", () => { - window.dispatchEvent(new Event("pointerdown")) - const panel = setup(true) - - panel.setIdentity("b") - panel.setOpen(false) - expect(frames.size).toBeGreaterThan(0) - settle() - - panel.setOpen(true) - settle() - panel.setOpen(false) - panel.setIdentity("c") - expect(panel.present()).toBe(false) - expect(panel.painted.width).toBe(0) - expect(panel.closed).toHaveLength(1) - expect(frames.size).toBe(0) -}) diff --git a/packages/gui-extensions/src/debug/i18n/en.ts b/packages/gui-extensions/src/debug/i18n/en.ts index 637524e798c..31efa6872b7 100644 --- a/packages/gui-extensions/src/debug/i18n/en.ts +++ b/packages/gui-extensions/src/debug/i18n/en.ts @@ -1,6 +1,5 @@ export default { "command.toggle": "Toggle debug bar", - "command.motion": "Toggle motion controls", "status.toggle": "Toggle debug tools", ariaLabel: "Development performance diagnostics", providerAriaLabel: "Provider performance diagnostics", diff --git a/packages/gui-extensions/src/debug/motion.tsx b/packages/gui-extensions/src/debug/motion.tsx index d073ba39024..117fd58f607 100644 --- a/packages/gui-extensions/src/debug/motion.tsx +++ b/packages/gui-extensions/src/debug/motion.tsx @@ -1,31 +1,74 @@ -import { onCleanup, Show } from "solid-js" +import { onCleanup, onMount } from "solid-js" import { Portal } from "solid-js/web" -// The Motion workspace and its floating button at the window's bottom-right corner. knobs loads once it opens. -export default function Motion(props: { open: boolean; button: boolean; title: string; toggle: () => void }) { - return ( - - - - - - - - - ) -} +// Where the panel was dragged to, as an offset from its bottom-right home; kept per device. +const key = "opencode.motion.panel" -function Workspace(props: { title: string }) { +// The Motion workspace, docked above the debug bar's right end until its header is dragged. knobs loads once it opens. +export default function Motion(props: { title: string }) { let container!: HTMLDivElement let unmount: (() => void) | undefined let disposed = false + const offset = load() + + const place = () => (container.style.transform = `translate(${offset.x}px, ${offset.y}px)`) + + // Moves by at most what keeps the whole panel in the window, reading layout once rather than every frame. + const bounds = () => { + const rect = container.getBoundingClientRect() + + return { + x: [offset.x - rect.left, offset.x + window.innerWidth - rect.right], + y: [offset.y - rect.top, offset.y + window.innerHeight - rect.bottom], + } + } + + const clamp = (limits: ReturnType, x: number, y: number) => { + offset.x = Math.min(Math.max(x, limits.x[0]), Math.max(limits.x[0], limits.x[1])) + offset.y = Math.min(Math.max(y, limits.y[0]), Math.max(limits.y[0], limits.y[1])) + place() + } + + const settle = () => { + clamp(bounds(), offset.x, offset.y) + save(offset) + } + + const grab = (event: PointerEvent) => { + if (event.button !== 0 || !(event.target instanceof Element)) return + + const header = event.target.closest(".knobs-panel > header") + + if (!header || event.target.closest("button, input, [role=tab]")) return + event.preventDefault() + const limits = bounds() + const start = { x: event.clientX - offset.x, y: event.clientY - offset.y } + header.setPointerCapture(event.pointerId) + + const move = (moved: PointerEvent) => clamp(limits, moved.clientX - start.x, moved.clientY - start.y) + + const drop = () => { + header.removeEventListener("pointermove", move) + header.removeEventListener("pointerup", drop) + header.removeEventListener("pointercancel", drop) + save(offset) + } + + header.addEventListener("pointermove", move) + header.addEventListener("pointerup", drop) + header.addEventListener("pointercancel", drop) + } + + onMount(() => { + place() + const observer = new ResizeObserver(settle) + observer.observe(container) + window.addEventListener("resize", settle) + onCleanup(() => { + observer.disconnect() + window.removeEventListener("resize", settle) + }) + }) onCleanup(() => { disposed = true @@ -37,9 +80,28 @@ function Workspace(props: { title: string }) { }) return ( -
+ +
+ ) } + +function load() { + try { + const saved = JSON.parse(localStorage.getItem(key) ?? "{}") + + return { x: Number(saved.x) || 0, y: Number(saved.y) || 0 } + } catch { + return { x: 0, y: 0 } + } +} + +function save(offset: { x: number; y: number }) { + try { + localStorage.setItem(key, JSON.stringify(offset)) + } catch {} +} diff --git a/packages/gui-extensions/src/debug/renderer.tsx b/packages/gui-extensions/src/debug/renderer.tsx index 3ab43c7e863..700be0c5c3e 100644 --- a/packages/gui-extensions/src/debug/renderer.tsx +++ b/packages/gui-extensions/src/debug/renderer.tsx @@ -4,7 +4,10 @@ import type Debug from "./index" const setup: Setup = (ctx) => { const DebugBar = lazy(() => import("./bar")) - const Motion = lazy(() => import("./motion")) + // Motion tuning lives only in the debug bar, on the dev server and in Beta. Both are fixed at build time, so other + // builds, prod included, drop the import and never contain knobs. + const motionTools = import.meta.env.DEV || import.meta.env.VITE_OPENCODE_CHANNEL === "beta" + const Motion = motionTools ? lazy(() => import("./motion")) : undefined const channel = ctx.build.channel // Window-local; every window starts with the bar hidden. const [visible, setVisible] = createSignal(false) @@ -19,30 +22,17 @@ const setup: Setup = (ctx) => { setMotion((value) => !value) } - // Motion tuning is a dev-server tool: no built app (dev, beta or prod channel) ships its button or panel. - const motionTools = import.meta.env.DEV - ctx.add( Command, (): Command => ({ id: "toggle", title: ctx.t("command.toggle"), group: ctx.t("command.category.view"), + keywords: "dev bar developer tools diagnostics", run: toggle, }), ) - if (motionTools) - ctx.add( - Command, - (): Command => ({ - id: "motion", - title: ctx.t("command.motion"), - group: ctx.t("command.category.view"), - run: toggleMotion, - }), - ) - ctx.add(Slot, { at: "window.bottom", render: () => ( @@ -51,16 +41,19 @@ const setup: Setup = (ctx) => { - - - - - + {Motion && ( + // The Motion workspace opens only from the debug bar's MOTION cell, and shows while the bar does. + + + + + + )} ), }) diff --git a/packages/gui-extensions/src/sdk/registries.ts b/packages/gui-extensions/src/sdk/registries.ts index a4b94a10b62..bf09b2149d3 100644 --- a/packages/gui-extensions/src/sdk/registries.ts +++ b/packages/gui-extensions/src/sdk/registries.ts @@ -17,6 +17,8 @@ export interface Command { readonly title: string /** The palette's second line. */ readonly description?: string + /** Extra words the palette matches, separated by spaces, e.g. "dev bar" for the debug bar. */ + readonly keywords?: string /** The palette category the command is listed under; translated text. */ readonly group?: string /** diff --git a/packages/ui/src/components/image-flight.test.ts b/packages/ui/src/components/image-flight.test.ts index 464fe6df4fb..43591ff3e59 100644 --- a/packages/ui/src/components/image-flight.test.ts +++ b/packages/ui/src/components/image-flight.test.ts @@ -102,6 +102,17 @@ describe("createFlight", () => { expect(flight.frame()).toEqual(preview) }) + test("keeps its launch shape on a slower spring, stretched in time", () => { + const snappy = createFlight(thumbnail) + const calm = createFlight(thumbnail) + snappy.to(preview, KICK.open) + calm.to(preview, KICK.open, { duration: 0.4, bounce: 0 }) + snappy.step(1 / 60) + calm.step(2 / 60) + + expect(progress(calm)).toBeCloseTo(progress(snappy), 5) + }) + test("turns around mid-flight from where it is, carrying its velocity", () => { const flight = createFlight(thumbnail) flight.to(preview, KICK.open) diff --git a/packages/ui/src/components/image-flight.ts b/packages/ui/src/components/image-flight.ts index 6ca2de2cc1b..1ad9d8e72f0 100644 --- a/packages/ui/src/components/image-flight.ts +++ b/packages/ui/src/components/image-flight.ts @@ -15,10 +15,11 @@ export type FlightFrame = { image: Box; clip: Box; radius: number } /** * Claude's preview, fitted frame by frame: one critically damped spring for both directions, launched with a push of - * this many times the remaining distance per second. A spring from rest starts too slowly to match. + * `KICK` times the remaining distance per second. A spring from rest starts too slowly to match. */ export const FLIGHT_SPRING = { duration: 0.2, bounce: 0 } +/** The launch push on `FLIGHT_SPRING`. Other durations scale it with the spring, so a slower spring keeps its shape. */ export const KICK = { open: 14, close: 32.5 } /** A flight frame with one spring per edge, so heading somewhere new mid-flight keeps the velocity it already has. */ @@ -26,13 +27,15 @@ export function createFlight(frame: FlightFrame) { const springs = values(frame).map((value) => Spring.fromDuration(value, FLIGHT_SPRING)) return { - /** Heads for `frame` on `tuning`, adding a launch velocity of `kick` times the remaining distance per second. */ + /** Heads for `frame` on `tuning`, launched with a `KICK` push scaled to the spring's duration. */ to(frame: FlightFrame, kick: number, tuning: DurationOptions = FLIGHT_SPRING) { + const push = (kick * FLIGHT_SPRING.duration) / tuning.duration + values(frame).forEach((target, index) => { const spring = springs[index] spring.omega = (2 * Math.PI) / tuning.duration spring.zeta = 1 - tuning.bounce - spring.set(target, { velocity: spring.velocity + kick * (target - spring.value) }) + spring.set(target, { velocity: spring.velocity + push * (target - spring.value) }) }) }, /** Advances `dt` seconds; true until every edge has landed. */ diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx index 550fee33abb..7fa015cd81f 100644 --- a/packages/ui/src/components/image-preview.tsx +++ b/packages/ui/src/components/image-preview.tsx @@ -33,91 +33,22 @@ const STEP = Math.SQRT2 const group = "Image open" -// Claude's backdrop dims from rest, more gently than the image flies. Without a flight (reduced motion, or no thumbnail -// to fly to), the preview only fades. -const claude = { - springs: { - imageOpen: FLIGHT_SPRING, - imageClose: FLIGHT_SPRING, - imageScrimOpen: { duration: 0.34, bounce: 0 }, - imageScrimClose: { duration: 0.2, bounce: 0 }, +// One spring carries the open and the close, and the backdrop dims on it too. Snappy is Claude's measured preview. +const tuning = motionTuning.spring("imagePreview", { + label: "Spring", + group, + presets: { + snappy: FLIGHT_SPRING, + smooth: { duration: 0.3, bounce: 0 }, + calm: { duration: 0.4, bounce: 0 }, }, - numbers: { imageOpenKick: KICK.open, imageCloseKick: KICK.close, imageFade: 0.15 }, -} +}) -const presets = { - "Claude match": claude, - Calm: { - springs: { - imageOpen: { duration: 0.32, bounce: 0 }, - imageClose: { duration: 0.26, bounce: 0 }, - imageScrimOpen: { duration: 0.45, bounce: 0 }, - imageScrimClose: { duration: 0.28, bounce: 0 }, - }, - numbers: { imageOpenKick: 6, imageCloseKick: 16, imageFade: 0.2 }, - }, -} - -const kick = { group, min: 0, max: 60, step: 0.5, unit: "×/s", time: "rate" } as const - -const tuning = { - open: motionTuning.spring("imageOpen", { label: "Open spring", group, ...claude.springs.imageOpen }), - openKick: motionTuning.number("imageOpenKick", { - ...kick, - label: "Open launch velocity", - value: claude.numbers.imageOpenKick, - }), - close: motionTuning.spring("imageClose", { label: "Close spring", group, ...claude.springs.imageClose }), - closeKick: motionTuning.number("imageCloseKick", { - ...kick, - label: "Close launch velocity", - value: claude.numbers.imageCloseKick, - }), - scrimOpen: motionTuning.spring("imageScrimOpen", { label: "Backdrop in", group, ...claude.springs.imageScrimOpen }), - scrimClose: motionTuning.spring("imageScrimClose", { - label: "Backdrop out", - group, - ...claude.springs.imageScrimClose, - }), - fade: motionTuning.number("imageFade", { - label: "Reduced-motion fade", - group, - value: claude.numbers.imageFade, - min: 0.05, - max: 1, - step: 0.01, - unit: "s", - time: "duration", - }), -} - -Object.entries(presets).forEach(([name, preset]) => - motionTuning.action({ - id: `imagePreview.preset.${name}`, - label: `Preset: ${name}`, - group, - run: () => { - const knobs = motionTuning.list() - - Object.entries(preset.springs).forEach(([id, value]) => { - const knob = knobs.find((item) => item.id === id) - - if (knob?.kind === "spring") motionTuning.set(knob, value) - }) - - Object.entries(preset.numbers).forEach(([id, value]) => { - const knob = knobs.find((item) => item.id === id) - - if (knob?.kind === "number") motionTuning.set(knob, value) - }) - }, - }), -) - -const fade = () => ({ duration: Math.max(0.03, tuning.fade()), bounce: 0 }) +// Without a flight (reduced motion, or no thumbnail to fly to), the preview only fades. +const fade = { duration: 0.15, bounce: 0 } // How long the dialog stays mounted after a close starts, in milliseconds: the flight lands well within this. -const exitDuration = () => Math.max(tuning.close().duration, tuning.scrimClose().duration, tuning.fade()) * 1800 +const exitDuration = () => Math.max(tuning().duration, fade.duration) * 1800 const CENTER = { x: 0, y: 0 } @@ -136,7 +67,7 @@ export function ImagePreview(props: ImagePreviewProps) { // The flight under way: where the stand-in is laid out (the flight only transforms and clips it), its springs, and // what happens when it lands. let course: { at: Box; flight: ReturnType; landed: () => void } | undefined - const dim = Spring.fromDuration(0, claude.springs.imageScrimOpen) + const dim = Spring.fromDuration(0, FLIGHT_SPRING) // Without a flight the panel and any stand-in fade with the backdrop; with one, the panel follows it more steeply, // so it settles in around the image as it lands and clears before the image leaves. let plain = false @@ -204,7 +135,7 @@ export function ImagePreview(props: ImagePreviewProps) { if (stage() !== "pending" || !dialog.isOpen()) return const from = prefersReducedMotion() ? undefined : originFrame() holdExit() - dimTo(1, from ? tuning.scrimOpen() : fade()) + dimTo(1, from ? tuning() : fade) if (!from) { plain = true @@ -215,7 +146,7 @@ export function ImagePreview(props: ImagePreviewProps) { } const flight = createFlight(from) - flight.to(restFrame(), tuning.openKick(), tuning.open()) + flight.to(restFrame(), KICK.open, tuning()) course = { at: place(fitted()), flight, landed: () => setStage("rest") } hideOrigin() paint() @@ -228,7 +159,7 @@ export function ImagePreview(props: ImagePreviewProps) { if (stage() === "pending") return const to = prefersReducedMotion() ? undefined : originFrame() - dimTo(0, to ? tuning.scrimClose() : fade()) + dimTo(0, to ? tuning() : fade) if (!to) { plain = true @@ -238,7 +169,7 @@ export function ImagePreview(props: ImagePreviewProps) { // A close mid-open turns the flight around where it is, keeping its velocity. course ??= { at: place(fitted()), flight: createFlight(restFrame()), landed: showOrigin } - course.flight.to(to, tuning.closeKick(), tuning.close()) + course.flight.to(to, KICK.close, tuning()) course.landed = showOrigin hideOrigin() paint() @@ -603,7 +534,7 @@ function addReplayActions() { // The flight starts once the image has decoded; close a little way into it. const wait = () => { if (document.querySelector('[data-component="image-preview"][data-stage="flying"]')) - return void setTimeout(close, tuning.open().duration * 350) + return void setTimeout(close, tuning().duration * 350) if (performance.now() - start < 2000) requestAnimationFrame(wait) } diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts index 600a5c3740c..ce7e7a2515a 100644 --- a/packages/ui/src/motion-tuning-panel.ts +++ b/packages/ui/src/motion-tuning-panel.ts @@ -1,14 +1,12 @@ -import type { SpringValue } from "knobs" -import { motionTuning, type MotionKnob } from "./motion-tuning" +import type { DurationOptions } from "./motion" +import { motionPresets, motionTuning, type MotionKnob } from "./motion-tuning" const global = "Global" -// Kit's palette, applied to every spring at once. -const durations = { snappy: 0.25, calm: 0.3, smooth: 0.45 } - /** - * Mounts the Motion workspace into `container`: a tab per surface, Global on every tab, an action row per replay - * button, a "Jump to" palette over sections, actions and presets, presets and copy as code. knobs loads on first + * Mounts the Motion workspace into `container`: a tab per surface under the Snappy, Smooth and Calm presets for every + * spring at once. Each tab shows one Global card (speed, slow motion and other settings every surface reads), then one + * card for its surface: its springs and knobs, with its replay buttons under a Replay sub-heading. knobs loads on first * mount, so importing this module costs nothing until then. Works anywhere a surface registered its knobs, including * Storybook stories. Returns its removal. */ @@ -25,10 +23,7 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri } async function start() { - const [{ action, defineControls, knob, spring, toggle }, { commandPalette, commandsFrom }] = await Promise.all([ - import("knobs"), - import("knobs/ui"), - ]) + const { action, defineControls, knob, spring, toggle } = await import("knobs") const field = (item: MotionKnob) => { const meta = { label: item.label, group: item.group, tab: item.group === global ? undefined : item.group } @@ -40,10 +35,10 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri return knob(item.initial, { ...meta, min: item.min, max: item.max, step: item.step, unit: item.unit ?? "×" }) } - const apply = (item: MotionKnob, value: SpringValue | boolean | number) => { + const apply = (item: MotionKnob, value: MotionKnob["value"]) => { if (item.kind === "spring") { // SAFETY: a spring knob's field is a spring field. - return motionTuning.set(item, value as SpringValue) + return motionTuning.set(item, value as DurationOptions) } if (item.kind === "toggle") { @@ -67,50 +62,41 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri const springs = list.flatMap((item) => (item.kind === "spring" ? [item] : [])) const sections = [...new Set([...list, ...actions].map((item) => item.group))].filter((group) => group !== global) - const palette = commandPalette({ - hotkey: false, - recents: false, - placeholder: "Jump to a section, action or preset", - commands: () => [ - ...sections.map((id) => ({ - id: `section.${id}`, - label: id, - section: "Sections", - run: () => { - section = id - mount() - }, - })), - ...commandsFrom(controls, { fields: false }).filter((command) => command.label !== "Jump to…"), - ], - }) - const controls = defineControls( "opencode-motion", { - ...Object.fromEntries(list.map((item) => [item.id, field(item)])), + // Global first, then each section's knobs and its replay buttons together, so each shows as one card. + ...Object.fromEntries(list.filter((item) => item.group === global).map((item) => [item.id, field(item)])), ...Object.fromEntries( - actions.map((item) => [ - `action.${item.id}`, - action(item.label, { group: item.group, tab: item.group, run: item.run }), - ]), + [...list, ...actions] + .filter((item) => item.group !== global) + .toSorted((a, b) => sections.indexOf(a.group) - sections.indexOf(b.group)) + .map((item) => + "run" in item + ? [ + `action.${item.id}`, + action(item.label, { group: item.group, tab: item.group, subsection: "Replay", run: item.run }), + ] + : [item.id, field(item)], + ), ), - ...Object.fromEntries([["jump", action("Jump to…", { group: global, run: () => palette.open() })]]), }, { // knobs enables itself only on localhost; this module is only loaded in developer channels. enabled: true, ship: () => import("knobs/panel"), persist: "variants", - presets: Object.fromEntries( - Object.entries(durations).map(([name, duration]) => [ - name, - Object.fromEntries(springs.map((item) => [item.id, { duration, bounce: 0 }])), - ]), - ), + // The defaults are Snappy; the strip names them so rather than "default". + defaultLabel: motionPresets.snappy, + presetLabels: { smooth: motionPresets.smooth, calm: motionPresets.calm }, + presets: { + smooth: Object.fromEntries(springs.map((item) => [item.id, item.presets.smooth])), + calm: Object.fromEntries(springs.map((item) => [item.id, item.presets.calm])), + }, onChange: (patch) => list.forEach((item) => { - const value = patch[item.id] + // SAFETY: each field was made from its knob above, so its value is that knob's kind. + const value = patch[item.id] as MotionKnob["value"] | undefined if (value !== undefined) apply(item, value) }), @@ -137,7 +123,6 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri unmount = () => { stopWatch() stopPanel() - palette.dispose() } } diff --git a/packages/ui/src/motion-tuning.test.ts b/packages/ui/src/motion-tuning.test.ts new file mode 100644 index 00000000000..93c6a1c4290 --- /dev/null +++ b/packages/ui/src/motion-tuning.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { motionGlobals, motionTuning } from "./motion-tuning" + +const presets = { + snappy: { duration: 0.15, bounce: 0 }, + smooth: { duration: 0.25, bounce: 0 }, + calm: { duration: 0.4, bounce: 0 }, +} + +afterEach(() => { + motionTuning.set(motionGlobals.speed, 1) + motionTuning.set(motionGlobals.slow, false) +}) + +describe("motionTuning.spring", () => { + test("ships Snappy and keeps the other presets on the knob", () => { + const spring = motionTuning.spring("test.shared", { label: "Spring", group: "Test", presets }) + const knob = motionTuning.list().find((item) => item.id === "test.shared") + + expect(spring()).toEqual(presets.snappy) + expect(knob?.kind === "spring" && knob.initial).toEqual(presets.snappy) + expect(knob?.kind === "spring" && knob.presets).toEqual({ smooth: presets.smooth, calm: presets.calm }) + }) + + test("runs a preset applied to its knob, stretched by the global speed and slow motion", () => { + const spring = motionTuning.spring("test.scaled", { label: "Spring", group: "Test", presets }) + const knob = motionTuning.list().find((item) => item.id === "test.scaled") + + if (knob?.kind !== "spring") throw new Error("expected a spring knob") + motionTuning.set(knob, knob.presets.calm) + expect(spring()).toEqual({ duration: 0.4, bounce: 0 }) + + motionTuning.set(motionGlobals.speed, 2) + expect(spring().duration).toBeCloseTo(0.2) + + motionTuning.set(motionGlobals.slow, true) + expect(spring().duration).toBeCloseTo(0.8) + }) +}) diff --git a/packages/ui/src/motion-tuning.ts b/packages/ui/src/motion-tuning.ts index e105b19e821..e926f4515b3 100644 --- a/packages/ui/src/motion-tuning.ts +++ b/packages/ui/src/motion-tuning.ts @@ -13,8 +13,13 @@ type Knob = { value: Value } +/** The presets every spring offers. Its shipped value is Snappy; Smooth and Calm are slower, all without bounce. */ +export const motionPresets = { snappy: "Snappy", smooth: "Smooth", calm: "Calm" } as const + +export type MotionPreset = keyof typeof motionPresets + export type MotionKnob = - | Knob<"spring", DurationOptions> + | (Knob<"spring", DurationOptions> & { presets: Record, DurationOptions> }) | Knob<"toggle", boolean> | (Knob<"number", number> & { min: number; max: number; step: number; unit?: string }) @@ -22,7 +27,8 @@ export type MotionAction = { id: string; label: string; group: string; run: () = type Meta = { label: string; group: string } -const key = "opencode.motion.v1" +// Bumped when knobs are merged or renamed, so values saved for the old ones are dropped. +const key = "opencode.motion.v2" const knobs = new Map() @@ -35,8 +41,8 @@ const saved = load() export const motionTuning = { /** The spring to run now: its tuned duration scaled by the global speed and slow motion. */ - spring(id: string, meta: Meta & DurationOptions) { - const initial = { duration: meta.duration, bounce: meta.bounce } + spring(id: string, meta: Meta & { presets: Record }) { + const initial = meta.presets.snappy const knob = register({ kind: "spring", @@ -44,11 +50,12 @@ export const motionTuning = { label: meta.label, group: meta.group, initial, - value: { duration: number(`${id}.duration`, meta.duration), bounce: number(`${id}.bounce`, meta.bounce) }, + value: { duration: number(`${id}.duration`, initial.duration), bounce: number(`${id}.bounce`, initial.bounce) }, + presets: { smooth: meta.presets.smooth, calm: meta.presets.calm }, }) return (): DurationOptions => ({ - duration: Math.max(0.03, (knob.value.duration * (slow.value ? 4 : 1)) / speed.value), + duration: Math.max(0.03, knob.value.duration * timeScale()), bounce: Math.min(0.95, Math.max(0, knob.value.bounce)), }) }, @@ -168,6 +175,8 @@ function number(id: string, fallback: number) { function load(): Record { try { + localStorage.removeItem("opencode.motion.v1") + return JSON.parse(localStorage.getItem(key) ?? "{}") } catch { return {}