refactor(app): instant sidebars and a tidier Motion workspace (#56996)

This commit is contained in:
Kit Langton
2026-10-19 12:13:27 -07:00
committed by GitHub
Unverified
parent e79de6666b
commit 538ef7f78d
22 changed files with 349 additions and 575 deletions
@@ -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))
}
})
+4 -3
View File
@@ -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()
@@ -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,
+17 -43
View File
@@ -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<string>((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<boolean>((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: {
</div>
</Show>
<div
ref={chat}
class="@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none"
data-slot="session-chat-panel"
data-scrollbar-hidden={store.timelineScrollbarHidden}
style={{ width: screen.panel.width() }}
onPointerMove={revealTimelineScrollbar}
onPointerDown={revealTimelineScrollbar}
on:wheel={{ passive: true, handleEvent: revealTimelineScrollbar }}
@@ -406,13 +381,12 @@ function SessionScreenContent(props: {
</Show>
</div>
<Show when={sideMotion.present() || store.sideTabsPresent}>
<Show when={sideVisible() || store.sideTabsPresent}>
<div
data-slot="session-side-panel-presence"
classList={{
"relative z-0 min-w-0 h-full flex-1 overflow-visible": sideMotion.present(),
"absolute inset-y-0 end-0 z-0 w-0 invisible pointer-events-none overflow-visible":
!sideMotion.present(),
"relative z-0 min-w-0 h-full flex-1 overflow-visible": sideVisible(),
"absolute inset-y-0 end-0 z-0 w-0 invisible pointer-events-none overflow-visible": !sideVisible(),
}}
>
<div
@@ -425,14 +399,14 @@ function SessionScreenContent(props: {
<div
data-slot="session-side-region-presence"
class="absolute inset-0"
classList={{ invisible: !sideMotion.present() }}
classList={{ invisible: !sideVisible() }}
>
<SideRegion
screen={props.screen}
view={props.view()}
region={region}
sidebar={sidebar}
fileTree={sideFileTree()}
fileTree={screen.files.open()}
present={store.sideTabsPresent}
size={screen.size}
/>
@@ -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
+3 -1
View File
@@ -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 }) {
@@ -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,
-139
View File
@@ -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<boolean>
spring: () => DurationOptions
paint: (closed: number) => void
/** A change shows the new state without motion, e.g. switching to another session's layout. */
identity?: Accessor<unknown>
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 }
}
+5 -23
View File
@@ -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()}
>
<div class="absolute inset-0 overflow-hidden" classList={{ invisible: !sidebarMotion.present() }}>
<div
class="absolute inset-0 overflow-hidden"
classList={{ invisible: !preferences.appearance.sidebarOpen() }}
>
<div
ref={(element) => 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)]"
@@ -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)
})
})
@@ -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<T>(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")
}
@@ -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<number, FrameRequestCallback>
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)
})
@@ -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",
+88 -26
View File
@@ -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 (
<Portal>
<Show when={props.button}>
<button
type="button"
aria-pressed={props.open}
class="fixed bottom-2 right-2 z-50 rounded-full border border-border-weak-base bg-surface-raised-base px-2 py-0.5 font-mono text-[10px] font-black uppercase tracking-[0.04em] text-text-weak opacity-60 hover:opacity-100 aria-pressed:text-text-strong aria-pressed:opacity-100"
onClick={props.toggle}
>
{props.title}
</button>
</Show>
<Show when={props.open}>
<Workspace title={props.title} />
</Show>
</Portal>
)
}
// 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<typeof bounds>, 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<HTMLElement>(".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 (
<div
ref={container}
class="pointer-events-auto fixed bottom-10 right-3 z-50 max-h-[calc(100vh-6rem)] w-[320px] overflow-auto"
/>
<Portal>
<div
ref={container}
class="pointer-events-auto fixed bottom-10 right-3 z-50 max-h-[calc(100vh-6rem)] w-[320px] overflow-auto [&_.knobs-panel>header]:cursor-grab [&_.knobs-panel>header]:touch-none"
onPointerDown={grab}
/>
</Portal>
)
}
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 {}
}
+14 -21
View File
@@ -4,7 +4,10 @@ import type Debug from "./index"
const setup: Setup<typeof Debug> = (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<typeof Debug> = (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<typeof Debug> = (ctx) => {
<Suspense>
<DebugBar
diagnostics={import.meta.env.DEV}
motion={motionTools ? { open: motion(), toggle: toggleMotion } : undefined}
motion={Motion ? { open: motion(), toggle: toggleMotion } : undefined}
inline
/>
</Suspense>
</Show>
<Show when={motionTools}>
<Suspense>
<Motion open={motion()} button={!visible()} toggle={toggleMotion} title={ctx.t("motion.title")} />
</Suspense>
</Show>
{Motion && (
// The Motion workspace opens only from the debug bar's MOTION cell, and shows while the bar does.
<Show when={visible() && motion()}>
<Suspense>
<Motion title={ctx.t("motion.title")} />
</Suspense>
</Show>
)}
</>
),
})
@@ -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
/**
@@ -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)
+6 -3
View File
@@ -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. */
+18 -87
View File
@@ -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<typeof createFlight>; 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)
}
+30 -45
View File
@@ -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()
}
}
+39
View File
@@ -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)
})
})
+15 -6
View File
@@ -13,8 +13,13 @@ type Knob<Kind extends string, Value> = {
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<Exclude<MotionPreset, "snappy">, 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<string, MotionKnob>()
@@ -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<MotionPreset, DurationOptions> }) {
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<string, number> {
try {
localStorage.removeItem("opencode.motion.v1")
return JSON.parse(localStorage.getItem(key) ?? "{}")
} catch {
return {}