From b57f6692532f994092a3d36105e0daf7cb6002fe Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 16:10:50 -0400 Subject: [PATCH 01/11] feat(app): add a Low movement option for sidebar motion --- packages/app/src/session/screen.tsx | 4 + packages/app/src/shell/panel-motion.ts | 108 ++++++++++++++- packages/app/src/shell/shell.tsx | 5 + .../app/test-browser/panel-motion.test.ts | 128 ++++++++++++++++++ packages/ui/src/motion-tuning-panel.ts | 11 +- packages/ui/src/motion-tuning.ts | 27 +++- 6 files changed, 276 insertions(+), 7 deletions(-) diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 3c81502fd1ba..135636ebae7e 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -152,6 +152,7 @@ function SessionScreenContent(props: { // 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 + let sidePresence: HTMLDivElement | undefined // Once opened, the side region stays mounted and is skipped by content-visibility when a close settles, so neither // direction mounts its contents mid-flight, and a reopen reuses their style and layout. @@ -182,6 +183,8 @@ function SessionScreenContent(props: { chat.style.width = closed === 0 ? open : `calc(${open} + (100% - ${open}) * ${closed})` }, + element: () => sidePresence, + offset: 12, }) replayPanelToggle({ @@ -482,6 +485,7 @@ function SessionScreenContent(props: {
void }) { onCleanup(motionTuning.action({ id: `sidebars.${input.id}`, label: input.label, group, run: input.toggle })) @@ -48,6 +81,12 @@ export function createPanelMotion(input: { onClosed?: () => void /** Reads layout in the frame loop's read phase, before each frame of a flight paints. */ measure?: () => void + /** + * Low movement: the panel layer that shifts and fades, and how far it shifts, in pixels toward its edge (negative for + * the inline-start edge; mirrored in RTL). + */ + element?: Accessor + offset?: number }) { const [present, setPresent] = createSignal(input.open()) // Percent closed, so the spring's rest threshold stays well under a pixel. @@ -57,6 +96,8 @@ export function createPanelMotion(input: { // A state `sync` asked to show at once, for when the change hasn't reached this motion yet, e.g. inside a batch. let snapTo: boolean | undefined let flying = false + // The low-movement flight in progress, if any. + let fades: Animation[] = [] const fly = (next: boolean) => { if (flying === next) return @@ -69,6 +110,8 @@ export function createPanelMotion(input: { const settle = () => { stop?.() stop = undefined + fades.forEach((animation) => animation.cancel()) + fades = [] fly(false) const queued = landed landed = [] @@ -102,6 +145,8 @@ export function createPanelMotion(input: { if (previous === undefined || previous[1] !== identity) { stop?.() stop = undefined + fades.forEach((animation) => animation.cancel()) + fades = [] fly(false) motion.snap(target) paint() @@ -113,6 +158,8 @@ export function createPanelMotion(input: { if (now || prefersReducedMotion() || style() === "instant") return snap(open) + if (style() === "low") return fade(open) + const tuning = spring() motion.omega = (2 * Math.PI) / tuning.duration motion.zeta = 1 - tuning.bounce @@ -135,22 +182,63 @@ export function createPanelMotion(input: { ), ) + /** + * Opens with the layout first, then the panel shifts in and fades up; closes in reverse, with the layout last. A + * reversal mid-flight turns around from where the panel is. + */ + const fade = (open: boolean) => { + stop?.() + stop = undefined + motion.snap(0) + paint() + fly(true) + // Deferred so a panel that mounts with this open exists before it animates; microtasks run before the next paint. + queueMicrotask(() => { + const element = input.element?.() + + if (input.open() !== open) return + + if (!element?.animate) return open ? settle() : snap(false) + + const x = (input.offset ?? 0) * (element.matches(":dir(rtl)") ? -1 : 1) + const away = { transform: `translateX(${x}px)`, opacity: `${faded}` } + const rest = { transform: "none", opacity: "1" } + const target = open ? rest : away + const start = fades.length > 0 ? getComputedStyle(element) : open ? away : rest + const tuning = lowShift() + fades.forEach((animation) => animation.cancel()) + fades = [ + element.animate({ transform: [start.transform, target.transform] }, { ...springEasing(tuning), fill: "both" }), + element.animate( + { opacity: [start.opacity, target.opacity] }, + { duration: lowFadeDuration() * 1000, easing: `cubic-bezier(${lowFade().join(", ")})`, fill: "both" }, + ), + ] + const current = fades + void Promise.all(current.map((animation) => animation.finished)).then( + () => current === fades && (open ? settle() : snap(false)), + () => undefined, + ) + }) + } + // Repaints at rest when the open size changes, e.g. a resize drag or a window resize. createEffect(paint) onCleanup(() => { stop?.() + fades.forEach((animation) => animation.cancel()) fly(false) }) return { present, sync: (open: boolean) => { - if (stop && input.open() === open) return snap(open) + if (flying && input.open() === open) return snap(open) snapTo = open }, whenSettled: (run: () => void) => { - if (stop) return void landed.push(run) + if (flying) return void landed.push(run) run() }, } @@ -186,3 +274,15 @@ export function guardClosingFocus(input: { onCleanup(() => root.removeEventListener("focusin", evict)) }) } + +/** A spring sampled per 60 Hz frame into a CSS `linear()` easing, ending once within 0.5% of rest. */ +function springEasing(tuning: DurationOptions) { + const spring = Spring.fromDuration(0, tuning).set(1) + const points = [0] + + while (points.length < 240 && (Math.abs(1 - points.at(-1)!) > 0.005 || Math.abs(spring.velocity) > 0.05)) + points.push(Math.round(spring.step(1 / 60) * 1000) / 1000) + points[points.length - 1] = 1 + + return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` } +} diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 5628933fe8b0..4eaac7a76b8a 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -29,10 +29,12 @@ export default function Layout(props: ParentProps) { tabsWidth: number tabsMount: HTMLElement | undefined tabsAside: HTMLElement | undefined + tabsPanel: HTMLElement | undefined }>({ tabsWidth: 260, tabsMount: undefined, tabsAside: undefined, + tabsPanel: undefined, }) const verticalTabs = () => preferences.appearance.tabLayout() !== "horizontal" && !mobile() @@ -52,6 +54,8 @@ export default function Layout(props: ParentProps) { paintCollapsedSlots(closed) }, measure: () => measureSidebarToggles(state.tabsWidth), + element: () => state.tabsPanel, + offset: -10, }) replayPanelToggle({ id: "sidebar", label: "Toggle the left sidebar", toggle: toggleSidebar }) @@ -123,6 +127,7 @@ export default function Layout(props: ParentProps) { {/* Skipped rather than display: none or inert, so neither direction restyles the sidebar's contents and a reopen reuses their layout. */}
setState("tabsPanel", element)} class="absolute inset-0 overflow-hidden [contain:layout_style_paint]" classList={{ "[content-visibility:hidden]": !sidebarMotion.present() }} > diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts index 85f2c3793aa9..48b1ad4a6493 100644 --- a/packages/app/test-browser/panel-motion.test.ts +++ b/packages/app/test-browser/panel-motion.test.ts @@ -235,3 +235,131 @@ it("moves focus out of a closing panel when the close starts and when it re-ente inner.focus() expect(document.activeElement).toBe(inner) }) + +type Played = { + keyframes: PropertyIndexedKeyframes + options: KeyframeAnimationOptions + cancelled: boolean + finish: () => void +} + +// happy-dom has no Web Animations, so the panel layer records what Low movement asks it to play. +function setupLow(initial: boolean) { + setStyle("low") + const [open, setOpen] = createSignal(initial) + const played: Played[] = [] + const closed: number[] = [] + const painted = { width: initial ? 200 : 0 } + const element = document.createElement("div") + Object.defineProperty(element, "animate", { + value: (keyframes: PropertyIndexedKeyframes, options: KeyframeAnimationOptions) => { + const finished = Promise.withResolvers() + const animation = { keyframes, options, cancelled: false, finish: () => finished.resolve() } + played.push(animation) + + return { + finished: finished.promise, + cancel: () => { + animation.cancelled = true + finished.reject(new Error("cancelled")) + }, + } + }, + }) + + const motion = createRoot((dispose) => { + cleanups.push(dispose) + + return createPanelMotion({ + open, + paint: (shut) => { + painted.width = 200 * (1 - shut) + }, + onClosed: () => closed.push(time), + element: () => element, + offset: -10, + }) + }) + + return { played, setOpen, closed, painted, motion } +} + +const tick = async () => { + await Promise.resolve() + await Promise.resolve() +} + +const land = async (played: Played[]) => { + played.filter((animation) => !animation.cancelled).forEach((animation) => animation.finish()) + await tick() +} + +it("Low movement opens with the layout first, then shifts the panel in on a spring and fades it up from 0.15", async () => { + const panel = setupLow(false) + panel.setOpen(true) + expect(panel.painted.width).toBe(200) + expect(panel.motion.present()).toBe(true) + await tick() + + const [shift, fade] = panel.played + expect(shift.keyframes.transform).toEqual(["translateX(-10px)", "none"]) + // A 240 ms spring without bounce: x(t) = 1 - (1 + ωt)e^(-ωt), sampled per 60 Hz frame. + const omega = (2 * Math.PI) / 0.24 + const points = String(shift.options.easing).slice("linear(".length, -1).split(", ").map(Number) + points.slice(1, -1).forEach((point, index) => { + const t = (index + 1) / 60 + expect(point).toBeCloseTo(1 - (1 + omega * t) * Math.exp(-omega * t), 2) + }) + expect(fade.keyframes.opacity).toEqual(["0.15", "1"]) + expect(fade.options).toMatchObject({ duration: 180, easing: "cubic-bezier(0.44, 0.75, 0.63, 1.03)" }) + expect(panelsMoving()).toBe(true) + + const ran: string[] = [] + panel.motion.whenSettled(() => ran.push("opened")) + await land(panel.played) + expect(ran).toEqual(["opened"]) + expect(panelsMoving()).toBe(false) +}) + +it("Low movement closes in reverse, keeping the layout until the panel has faded down", async () => { + const panel = setupLow(true) + panel.setOpen(false) + await tick() + + const [shift, fade] = panel.played + expect(shift.keyframes.transform).toEqual(["none", "translateX(-10px)"]) + expect(fade.keyframes.opacity).toEqual(["1", "0.15"]) + expect(panel.painted.width).toBe(200) + expect(panel.motion.present()).toBe(true) + + await land(panel.played) + expect(panel.painted.width).toBe(0) + expect(panel.motion.present()).toBe(false) + expect(panel.closed).toHaveLength(1) +}) + +it("Low movement turns around mid-flight, cancelling the first direction", async () => { + const panel = setupLow(true) + panel.setOpen(false) + await tick() + panel.setOpen(true) + await tick() + + expect(panel.played).toHaveLength(4) + expect(panel.played.slice(0, 2).every((animation) => animation.cancelled)).toBe(true) + expect(panel.played[2].keyframes.transform?.at(-1)).toBe("none") + await land(panel.played) + expect(panel.motion.present()).toBe(true) + expect(panel.closed).toEqual([]) +}) + +it("Low movement snaps under reduced motion", () => { + document.documentElement.dataset.motion = "reduce" + cleanups.push(() => delete document.documentElement.dataset.motion) + const panel = setupLow(true) + panel.setOpen(false) + + expect(panel.played).toEqual([]) + expect(panel.painted.width).toBe(0) + expect(panel.motion.present()).toBe(false) +}) diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts index 2668f38376b8..40e3ca4165b8 100644 --- a/packages/ui/src/motion-tuning-panel.ts +++ b/packages/ui/src/motion-tuning-panel.ts @@ -1,5 +1,5 @@ import type { DurationOptions } from "./motion" -import { motionPresets, motionTuning, type MotionKnob, type MotionPreset } from "./motion-tuning" +import { motionPresets, motionTuning, type Bezier, type MotionKnob, type MotionPreset } from "./motion-tuning" const global = "Global" @@ -26,7 +26,7 @@ export function mountMotionPanel( } async function start() { - const { action, choice, defineControls, knob, spring, toggle } = await import("knobs") + const { action, choice, defineControls, easing, 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 } @@ -35,6 +35,8 @@ export function mountMotionPanel( if (item.kind === "toggle") return toggle(item.initial, meta) + if (item.kind === "easing") return easing(item.initial, meta) + if (item.kind === "choice") return choice(item.initial, item.options, { ...meta, labels: item.labels }) return knob(item.initial, { ...meta, min: item.min, max: item.max, step: item.step, unit: item.unit ?? "×" }) @@ -46,6 +48,11 @@ export function mountMotionPanel( return motionTuning.set(item, value as DurationOptions) } + if (item.kind === "easing") { + // SAFETY: an easing knob's field is an easing field. + return motionTuning.set(item, [...(value as Bezier)] as Bezier) + } + if (item.kind === "toggle") { // SAFETY: a toggle knob's field is a toggle field. return motionTuning.set(item, value as boolean) diff --git a/packages/ui/src/motion-tuning.ts b/packages/ui/src/motion-tuning.ts index 24712c52c316..e31c91c3ab9f 100644 --- a/packages/ui/src/motion-tuning.ts +++ b/packages/ui/src/motion-tuning.ts @@ -18,8 +18,12 @@ export const motionPresets = { snappy: "Snappy", smooth: "Smooth", calm: "Calm" export type MotionPreset = keyof typeof motionPresets +/** A CSS `cubic-bezier(x1, y1, x2, y2)`. */ +export type Bezier = [number, number, number, number] + export type MotionKnob = | (Knob<"spring", DurationOptions> & { presets: Record }) + | Knob<"easing", Bezier> | Knob<"toggle", boolean> | (Knob<"number", number> & { min: number; max: number; step: number; unit?: string }) | (Knob<"choice", string> & { options: readonly [string, ...string[]]; labels: Record }) @@ -37,7 +41,7 @@ const actions = new Map() const listeners = new Set<() => void>() -// Saved as numbers only: `.duration` and `.bounce` for springs, 0 or 1 for toggles, the option's index for choices. +// Saved as numbers only: `.duration` and `.bounce` for springs, `.0`…`.3` for easings, 0 or 1 for toggles, the option's index for choices. const saved = load() export const motionTuning = { @@ -60,6 +64,20 @@ export const motionTuning = { bounce: Math.min(0.95, Math.max(0, knob.value.bounce)), }) }, + /** A cubic-bezier curve, edited with its handles. */ + easing(id: string, meta: Meta & { value: Bezier }) { + const knob = register({ + kind: "easing", + id, + label: meta.label, + group: meta.group, + initial: meta.value, + // SAFETY: mapping the four indices of a Bezier yields four numbers. + value: [0, 1, 2, 3].map((index) => number(`${id}.${index}`, meta.value[index])) as Bezier, + }) + + return () => knob.value + }, toggle(id: string, meta: Meta & { value: boolean }) { const knob = register({ kind: "toggle", @@ -212,6 +230,13 @@ function save() { [`${knob.id}.bounce`, knob.value.bounce, knob.initial.bounce], ] + if (knob.kind === "easing") + return knob.value.map((value, index): [string, number, number] => [ + `${knob.id}.${index}`, + value, + knob.initial[index], + ]) + if (knob.kind === "toggle") return [[knob.id, Number(knob.value), Number(knob.initial)]] if (knob.kind === "choice") From fe7d039b8137cb28b167271c12fdada246303a50 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 16:23:25 -0400 Subject: [PATCH 02/11] feat(app): Low movement controls under its Motion choice, with a layout glide --- packages/app/src/session/screen.tsx | 2 + packages/app/src/shell/panel-motion.ts | 119 ++++++++++++++++-- packages/app/src/shell/shell.tsx | 5 + .../app/test-browser/panel-motion.test.ts | 23 ++++ packages/ui/src/motion-tuning-panel.ts | 8 +- packages/ui/src/motion-tuning.ts | 9 +- 6 files changed, 151 insertions(+), 15 deletions(-) diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 135636ebae7e..c68199e857dd 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -185,6 +185,8 @@ function SessionScreenContent(props: { }, element: () => sidePresence, offset: 12, + neighbours: () => [[chat, "center"]], + resize: () => chat, }) replayPanelToggle({ diff --git a/packages/app/src/shell/panel-motion.ts b/packages/app/src/shell/panel-motion.ts index 40b1f34c97c7..e7fe814fa1ff 100644 --- a/packages/app/src/shell/panel-motion.ts +++ b/packages/app/src/shell/panel-motion.ts @@ -9,10 +9,18 @@ const [flights, setFlights] = createSignal(0) export const panelsMoving = () => flights() > 0 +const style = motionTuning.choice("sidebars.style", { + label: "Motion", + group, + value: "spring", + labels: { spring: "Spring", low: "Low movement", instant: "Instant" }, +}) + // The left sidebar and the side panel share one spring, so they move as one system. const spring = motionTuning.spring("sidebars", { label: "Spring", group, + when: () => style() === "spring", shipped: "smooth", presets: { snappy: { duration: 0.2, bounce: 0 }, @@ -21,18 +29,15 @@ const spring = motionTuning.spring("sidebars", { }, }) -const style = motionTuning.choice("sidebars.style", { - label: "Motion", - group, - value: "spring", - labels: { spring: "Spring", low: "Low movement", instant: "Instant" }, -}) +// Low movement: layout changes in one frame and only the panel moves, a few pixels toward its edge, while it fades. +// The neighbours beside it glide from their old box to the new one. All run as Web Animations, so the compositor plays +// them through main-thread work. +const low = () => style() === "low" -// Low movement: layout changes in one frame and only the panel moves, a few pixels toward its edge, while it fades. Both -// run as Web Animations on transform and opacity, so the compositor plays them through main-thread work. const lowShift = motionTuning.spring("sidebars.low.shift", { label: "Low movement: shift", group, + when: low, shipped: "smooth", presets: { snappy: { duration: 0.18, bounce: 0 }, @@ -44,12 +49,14 @@ const lowShift = motionTuning.spring("sidebars.low.shift", { const lowFade = motionTuning.easing("sidebars.low.fade", { label: "Low movement: fade", group, + when: low, value: [0.44, 0.75, 0.63, 1.03], }) const lowFadeDuration = motionTuning.number("sidebars.low.fadeDuration", { label: "Low movement: fade duration", group, + when: low, value: 0.18, min: 0.05, max: 0.6, @@ -58,9 +65,39 @@ const lowFadeDuration = motionTuning.number("sidebars.low.fadeDuration", { time: "duration", }) +// "flip" translates the neighbours from their old box on the compositor; "width" animates the resized box's real width, +// re-wrapping its content each frame. +const lowLayout = motionTuning.choice("sidebars.low.layout", { + label: "Low movement: layout", + group, + when: low, + value: "flip", + labels: { flip: "Glide (FLIP)", width: "Width", off: "Off" }, +}) + +const layoutSpring = (direction: string, label: string) => + motionTuning.spring(`sidebars.low.layout.${direction}`, { + label, + group, + when: () => low() && lowLayout() !== "off", + shipped: "smooth", + presets: { + snappy: { duration: 0.12, bounce: 0 }, + smooth: { duration: 0.16, bounce: 0 }, + calm: { duration: 0.24, bounce: 0 }, + }, + }) + +const lowLayoutOpen = layoutSpring("open", "Low movement: layout open") + +const lowLayoutClose = layoutSpring("close", "Low movement: layout close") + // The opacity a low-movement panel fades up from and down to. const faded = 0.15 +/** Where a neighbour's content sits: centred content follows its box's centre, start-pinned content its start edge. */ +export type Anchor = "center" | "start" + /** Adds a button that toggles a panel to the Motion workspace's Sidebars section while the panel is mounted. */ export function replayPanelToggle(input: { id: string; label: string; toggle: () => void }) { onCleanup(motionTuning.action({ id: `sidebars.${input.id}`, label: input.label, group, run: input.toggle })) @@ -87,6 +124,10 @@ export function createPanelMotion(input: { */ element?: Accessor offset?: number + /** Low movement: the boxes beside the panel that glide into place when its layout changes, and how. */ + neighbours?: () => Array<[HTMLElement | null | undefined, Anchor]> + /** Low movement, width layout: the box whose real width animates. */ + resize?: () => HTMLElement | null | undefined }) { const [present, setPresent] = createSignal(input.open()) // Percent closed, so the spring's rest threshold stays well under a pixel. @@ -189,8 +230,10 @@ export function createPanelMotion(input: { const fade = (open: boolean) => { stop?.() stop = undefined - motion.snap(0) - paint() + glide(true, () => { + motion.snap(0) + paint() + }) fly(true) // Deferred so a panel that mounts with this open exists before it animates; microtasks run before the next paint. queueMicrotask(() => { @@ -216,12 +259,58 @@ export function createPanelMotion(input: { ] const current = fades void Promise.all(current.map((animation) => animation.finished)).then( - () => current === fades && (open ? settle() : snap(false)), + () => current === fades && (open ? settle() : glide(false, () => snap(false))), () => undefined, ) }) } + /** + * Runs `change`, which moves the layout, then glides the neighbours from their old boxes to the new ones. The new + * boxes are read in a ResizeObserver pass, after the screen's own observers have settled their sizes for the change. + */ + const glide = (open: boolean, change: () => void) => { + const mode = lowLayout() + + const before = + mode === "flip" + ? (input.neighbours?.() ?? []).flatMap(([element, anchor]) => + element ? [{ element, anchor, x: anchorX(element, anchor) }] : [], + ) + : [] + + const box = mode === "width" ? input.resize?.() : undefined + + const width = box?.getBoundingClientRect().width + change() + + if (before.length === 0 && !box) return + const timing = springEasing(open ? lowLayoutOpen() : lowLayoutClose()) + + const observer = new ResizeObserver(() => { + observer.disconnect() + before.forEach(({ element, anchor, x }) => { + const dx = x - anchorX(element, anchor) + + if (Math.abs(dx) > 0.5) + element.animate( + { transform: [`translateX(${dx}px)`, "none"] }, + { ...timing, composite: "add", fill: "backwards" }, + ) + }) + + if (!box || width === undefined) return + const to = box.getBoundingClientRect().width + + if (Math.abs(to - width) > 0.5) + box.animate({ width: [`${width}px`, `${to}px`] }, { ...timing, fill: "backwards" }) + }) + + before.forEach(({ element }) => observer.observe(element)) + + if (box) observer.observe(box) + } + // Repaints at rest when the open size changes, e.g. a resize drag or a window resize. createEffect(paint) @@ -286,3 +375,11 @@ function springEasing(tuning: DurationOptions) { return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` } } + +function anchorX(element: HTMLElement, anchor: Anchor) { + const box = element.getBoundingClientRect() + + if (anchor === "center") return box.left + box.width / 2 + + return element.matches(":dir(rtl)") ? box.right : box.left +} diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 4eaac7a76b8a..f9a8f17a5c56 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -56,6 +56,11 @@ export default function Layout(props: ParentProps) { measure: () => measureSidebarToggles(state.tabsWidth), element: () => state.tabsPanel, offset: -10, + neighbours: () => [ + [document.querySelector('[data-slot="session-chat-panel"]'), "center"], + [document.querySelector('[data-slot="session-side-panel-presence"]'), "start"], + ], + resize: () => state.tabsAside, }) replayPanelToggle({ id: "sidebar", label: "Toggle the left sidebar", toggle: toggleSidebar }) diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts index 48b1ad4a6493..967facc089ba 100644 --- a/packages/app/test-browser/panel-motion.test.ts +++ b/packages/app/test-browser/panel-motion.test.ts @@ -363,3 +363,26 @@ it("Low movement snaps under reduced motion", () => { expect(panel.painted.width).toBe(0) expect(panel.motion.present()).toBe(false) }) + +it("shows only the selected Motion option's controls, under the Motion choice", () => { + const shown = () => + motionTuning + .list() + .filter((knob) => knob.group === "Sidebars" && (knob.when?.() ?? true)) + .map((knob) => knob.id) + + setStyle("spring") + expect(shown()).toEqual(["sidebars.style", "sidebars"]) + setStyle("low") + expect(shown()).toEqual([ + "sidebars.style", + "sidebars.low.shift", + "sidebars.low.fade", + "sidebars.low.fadeDuration", + "sidebars.low.layout", + "sidebars.low.layout.open", + "sidebars.low.layout.close", + ]) + setStyle("instant") + expect(shown()).toEqual(["sidebars.style"]) +}) diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts index 40e3ca4165b8..89e2bf64ab0f 100644 --- a/packages/ui/src/motion-tuning-panel.ts +++ b/packages/ui/src/motion-tuning-panel.ts @@ -67,13 +67,15 @@ export function mountMotionPanel( motionTuning.set(item, value as number) } - const identity = () => - [...motionTuning.list(), ...motionTuning.actions()].map((item) => `${item.group}/${item.id}`).join() + // Knobs whose `when` doesn't hold are left out; a change to it rebuilds the panel like a new knob would. + const shown = () => motionTuning.list().filter((item) => item.when?.() ?? true) + + const identity = () => [...shown(), ...motionTuning.actions()].map((item) => `${item.group}/${item.id}`).join() // Rebuilt when a surface registers or removes a knob or action. const mount = () => { unmount?.() - const list = motionTuning.list() + const list = shown() const actions = motionTuning.actions() const mounted = identity() const springs = list.flatMap((item) => (item.kind === "spring" ? [item] : [])) diff --git a/packages/ui/src/motion-tuning.ts b/packages/ui/src/motion-tuning.ts index e31c91c3ab9f..03be0c48ef5b 100644 --- a/packages/ui/src/motion-tuning.ts +++ b/packages/ui/src/motion-tuning.ts @@ -9,6 +9,8 @@ type Knob = { id: string label: string group: string + /** Shown only while this holds, e.g. the controls of the option a choice selects. */ + when?: () => boolean initial: Value value: Value } @@ -30,7 +32,7 @@ export type MotionKnob = export type MotionAction = { id: string; label: string; group: string; run: () => void } -type Meta = { label: string; group: string } +type Meta = { label: string; group: string; when?: () => boolean } // Bumped when knobs are merged or renamed, so values saved for the old ones are dropped. const key = "opencode.motion.v2" @@ -54,6 +56,7 @@ export const motionTuning = { id, label: meta.label, group: meta.group, + when: meta.when, initial, value: { duration: number(`${id}.duration`, initial.duration), bounce: number(`${id}.bounce`, initial.bounce) }, presets: meta.presets, @@ -71,6 +74,7 @@ export const motionTuning = { id, label: meta.label, group: meta.group, + when: meta.when, initial: meta.value, // SAFETY: mapping the four indices of a Bezier yields four numbers. value: [0, 1, 2, 3].map((index) => number(`${id}.${index}`, meta.value[index])) as Bezier, @@ -84,6 +88,7 @@ export const motionTuning = { id, label: meta.label, group: meta.group, + when: meta.when, initial: meta.value, value: number(id, meta.value ? 1 : 0) === 1, }) @@ -100,6 +105,7 @@ export const motionTuning = { id, label: meta.label, group: meta.group, + when: meta.when, initial: meta.value, value: options[number(id, options.indexOf(meta.value))] ?? meta.value, options, @@ -122,6 +128,7 @@ export const motionTuning = { id, label: meta.label, group: meta.group, + when: meta.when, initial: meta.value, value: number(id, meta.value), min: meta.min, From cbd729e41cda6b8173949e5bdc14d1f9b92c6f9d Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 16:32:24 -0400 Subject: [PATCH 03/11] feat(app): choose Low movement in the Knobs preset strip, swapping in its knobs --- packages/app/src/shell/panel-motion.ts | 42 +++++++------- .../app/test-browser/panel-motion.test.ts | 37 ++++++------ packages/ui/src/motion-tuning-panel.ts | 57 +++++++++++++++++-- packages/ui/src/motion-tuning.ts | 20 ++++++- 4 files changed, 111 insertions(+), 45 deletions(-) diff --git a/packages/app/src/shell/panel-motion.ts b/packages/app/src/shell/panel-motion.ts index e7fe814fa1ff..1f034b6fddf2 100644 --- a/packages/app/src/shell/panel-motion.ts +++ b/packages/app/src/shell/panel-motion.ts @@ -9,18 +9,19 @@ const [flights, setFlights] = createSignal(0) export const panelsMoving = () => flights() > 0 -const style = motionTuning.choice("sidebars.style", { - label: "Motion", - group, - value: "spring", - labels: { spring: "Spring", low: "Low movement", instant: "Instant" }, -}) +// Low movement, chosen in the preset strip beside Snappy, Smooth and Calm, swaps the section's knobs for its own: layout +// changes in one frame and only the panel moves, a few pixels toward its edge, while it fades. The neighbours beside it +// glide from their old box to the new one. All run as Web Animations, so the compositor plays them through main-thread +// work. +const low = motionTuning.mode("sidebars.low", { label: "Low movement", group }) + +const kit = () => !low() // The left sidebar and the side panel share one spring, so they move as one system. const spring = motionTuning.spring("sidebars", { label: "Spring", group, - when: () => style() === "spring", + when: kit, shipped: "smooth", presets: { snappy: { duration: 0.2, bounce: 0 }, @@ -29,13 +30,16 @@ const spring = motionTuning.spring("sidebars", { }, }) -// Low movement: layout changes in one frame and only the panel moves, a few pixels toward its edge, while it fades. -// The neighbours beside it glide from their old box to the new one. All run as Web Animations, so the compositor plays -// them through main-thread work. -const low = () => style() === "low" +const style = motionTuning.choice("sidebars.style", { + label: "Motion", + group, + when: kit, + value: "spring", + labels: { spring: "Spring", instant: "Instant" }, +}) const lowShift = motionTuning.spring("sidebars.low.shift", { - label: "Low movement: shift", + label: "Movement", group, when: low, shipped: "smooth", @@ -47,14 +51,14 @@ const lowShift = motionTuning.spring("sidebars.low.shift", { }) const lowFade = motionTuning.easing("sidebars.low.fade", { - label: "Low movement: fade", + label: "Fade", group, when: low, value: [0.44, 0.75, 0.63, 1.03], }) const lowFadeDuration = motionTuning.number("sidebars.low.fadeDuration", { - label: "Low movement: fade duration", + label: "Fade duration", group, when: low, value: 0.18, @@ -68,7 +72,7 @@ const lowFadeDuration = motionTuning.number("sidebars.low.fadeDuration", { // "flip" translates the neighbours from their old box on the compositor; "width" animates the resized box's real width, // re-wrapping its content each frame. const lowLayout = motionTuning.choice("sidebars.low.layout", { - label: "Low movement: layout", + label: "Layout", group, when: low, value: "flip", @@ -88,9 +92,9 @@ const layoutSpring = (direction: string, label: string) => }, }) -const lowLayoutOpen = layoutSpring("open", "Low movement: layout open") +const lowLayoutOpen = layoutSpring("open", "Layout open") -const lowLayoutClose = layoutSpring("close", "Low movement: layout close") +const lowLayoutClose = layoutSpring("close", "Layout close") // The opacity a low-movement panel fades up from and down to. const faded = 0.15 @@ -197,9 +201,9 @@ export function createPanelMotion(input: { return } - if (now || prefersReducedMotion() || style() === "instant") return snap(open) + if (now || prefersReducedMotion() || (!low() && style() === "instant")) return snap(open) - if (style() === "low") return fade(open) + if (low()) return fade(open) const tuning = spring() motion.omega = (2 * Math.PI) / tuning.duration diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts index 967facc089ba..e5a2c0ff4fb2 100644 --- a/packages/app/test-browser/panel-motion.test.ts +++ b/packages/app/test-browser/panel-motion.test.ts @@ -33,6 +33,7 @@ afterEach(() => { globalThis.requestAnimationFrame = originals.raf globalThis.cancelAnimationFrame = originals.caf setStyle("spring") + setLow(false) }) function setStyle(value: string) { @@ -41,6 +42,13 @@ function setStyle(value: string) { if (knob?.kind === "choice") motionTuning.set(knob, value) } +// Low movement is a mode, chosen in the preset strip rather than the Motion choice. +function setLow(on: boolean) { + const knob = motionTuning.list().find((item) => item.id === "sidebars.low") + + if (knob?.kind === "mode") motionTuning.set(knob, on) +} + function frame() { time += 1000 / 60 const next = [...frames.values()] @@ -245,7 +253,7 @@ type Played = { // happy-dom has no Web Animations, so the panel layer records what Low movement asks it to play. function setupLow(initial: boolean) { - setStyle("low") + setLow(true) const [open, setOpen] = createSignal(initial) const played: Played[] = [] const closed: number[] = [] @@ -364,25 +372,16 @@ it("Low movement snaps under reduced motion", () => { expect(panel.motion.present()).toBe(false) }) -it("shows only the selected Motion option's controls, under the Motion choice", () => { +it("swaps Kit's knobs for Low movement's while its mode is on", () => { const shown = () => motionTuning .list() - .filter((knob) => knob.group === "Sidebars" && (knob.when?.() ?? true)) - .map((knob) => knob.id) - - setStyle("spring") - expect(shown()).toEqual(["sidebars.style", "sidebars"]) - setStyle("low") - expect(shown()).toEqual([ - "sidebars.style", - "sidebars.low.shift", - "sidebars.low.fade", - "sidebars.low.fadeDuration", - "sidebars.low.layout", - "sidebars.low.layout.open", - "sidebars.low.layout.close", - ]) - setStyle("instant") - expect(shown()).toEqual(["sidebars.style"]) + .filter((knob) => knob.group === "Sidebars" && knob.kind !== "mode" && (knob.when?.() ?? true)) + .map((knob) => knob.label) + + expect(shown()).toEqual(["Spring", "Motion"]) + setLow(true) + expect(shown()).toEqual(["Movement", "Fade", "Fade duration", "Layout", "Layout open", "Layout close"]) + setLow(false) + expect(shown()).toEqual(["Spring", "Motion"]) }) diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts index 89e2bf64ab0f..9106425c6781 100644 --- a/packages/ui/src/motion-tuning-panel.ts +++ b/packages/ui/src/motion-tuning-panel.ts @@ -15,6 +15,8 @@ export function mountMotionPanel( options: { title?: string; section?: string; onClose?: () => void } = {}, ) { let unmount: (() => void) | undefined + // The preset chosen to leave a mode, applied once the rebuilt panel has the knobs it sets. + let switching: string | undefined let disposed = false let section = options.section @@ -33,7 +35,8 @@ export function mountMotionPanel( if (item.kind === "spring") return spring(item.initial, meta) - if (item.kind === "toggle") return toggle(item.initial, meta) + // Modes never reach here as rows; they sit in the preset strip. + if (item.kind === "toggle" || item.kind === "mode") return toggle(item.initial, meta) if (item.kind === "easing") return easing(item.initial, meta) @@ -53,7 +56,7 @@ export function mountMotionPanel( return motionTuning.set(item, [...(value as Bezier)] as Bezier) } - if (item.kind === "toggle") { + if (item.kind === "toggle" || item.kind === "mode") { // SAFETY: a toggle knob's field is a toggle field. return motionTuning.set(item, value as boolean) } @@ -75,11 +78,17 @@ export function mountMotionPanel( // Rebuilt when a surface registers or removes a knob or action. const mount = () => { unmount?.() - const list = shown() + const all = shown() + // Modes sit in the preset strip, not as rows. While one is on, the spring presets only switch back, then apply. + const modes = all.flatMap((item) => (item.kind === "mode" ? [item] : [])) + const active = modes.find((item) => item.value) + const list = all.filter((item) => item.kind !== "mode") const actions = motionTuning.actions() const mounted = identity() - const springs = list.flatMap((item) => (item.kind === "spring" ? [item] : [])) + const springs = active ? [] : list.flatMap((item) => (item.kind === "spring" ? [item] : [])) const preset = (id: MotionPreset) => Object.fromEntries(springs.map((item) => [item.id, item.presets[id]])) + // Easing editors open inline, like the spring rows beside them, rather than folded behind a chevron. + openEditors(list.flatMap((item) => (item.kind === "easing" ? [item.id] : []))) const sections = [...new Set([...list, ...actions].map((item) => item.group))].filter((group) => group !== global) const controls = defineControls( @@ -108,8 +117,13 @@ export function mountMotionPanel( persist: "variants", // Springs ship different presets, so the defaults get their own chip. defaultLabel: "Shipped", - presetLabels: motionPresets, - presets: { snappy: preset("snappy"), smooth: preset("smooth"), calm: preset("calm") }, + presetLabels: { ...motionPresets, ...Object.fromEntries(modes.map((item) => [item.id, item.label])) }, + presets: { + snappy: preset("snappy"), + smooth: preset("smooth"), + calm: preset("calm"), + ...Object.fromEntries(modes.map((item) => [item.id, {}])), + }, onChange: (patch) => list.forEach((item) => { // SAFETY: each field was made from its knob above, so its value is that knob's kind. @@ -123,6 +137,25 @@ export function mountMotionPanel( const sync = () => controls.sync(Object.fromEntries(list.map((item) => [item.id, item.value]))) sync() + // The strip shows the mode that's on; a preset chosen while a mode was on applies once its knobs are back. + if (active) controls.preset(active.id) + + if (!active && switching && switching in controls.presets) controls.preset(switching) + switching = undefined + + const stopModes = controls.subscribe(() => { + const chosen = controls.activePreset() + + modes.forEach((item) => { + const on = chosen?.kind === "preset" && chosen.id === item.id + + if (on === item.value) return + + if (!on) switching = chosen?.kind === "preset" ? chosen.id : chosen ? undefined : "default" + motionTuning.set(item, on) + }) + }) + const stopPanel = controls.mount(container, { theme: "auto", title: options.title ?? "Knobs", @@ -143,6 +176,7 @@ export function mountMotionPanel( unmount = () => { stopWatch() + stopModes() stopPanel() } } @@ -150,3 +184,14 @@ export function mountMotionPanel( if (!disposed) mount() } } + +// knobs remembers which rows' editors are open in this tab's session; adding ids there opens them on mount. +function openEditors(ids: string[]) { + if (ids.length === 0 || typeof sessionStorage === "undefined") return + const key = "knobs:opencode-motion:session:v1" + const session = JSON.parse(sessionStorage.getItem(key) ?? "{}") + const expanded: string[] = Array.isArray(session.expanded) ? session.expanded : [] + + if (ids.every((id) => expanded.includes(id))) return + sessionStorage.setItem(key, JSON.stringify({ ...session, expanded: [...new Set([...expanded, ...ids])] })) +} diff --git a/packages/ui/src/motion-tuning.ts b/packages/ui/src/motion-tuning.ts index 03be0c48ef5b..93fa5d98af53 100644 --- a/packages/ui/src/motion-tuning.ts +++ b/packages/ui/src/motion-tuning.ts @@ -26,6 +26,7 @@ export type Bezier = [number, number, number, number] export type MotionKnob = | (Knob<"spring", DurationOptions> & { presets: Record }) | Knob<"easing", Bezier> + | Knob<"mode", boolean> | Knob<"toggle", boolean> | (Knob<"number", number> & { min: number; max: number; step: number; unit?: string }) | (Knob<"choice", string> & { options: readonly [string, ...string[]]; labels: Record }) @@ -82,6 +83,23 @@ export const motionTuning = { return () => knob.value }, + /** + * An alternative to a section's springs, offered beside the Snappy, Smooth and Calm presets rather than as a row. + * Choosing it turns it on; choosing any other preset turns it off. Knobs can show only in one or the other with `when`. + */ + mode(id: string, meta: Meta) { + const knob = register({ + kind: "mode", + id, + label: meta.label, + group: meta.group, + when: meta.when, + initial: false, + value: number(id, 0) === 1, + }) + + return () => knob.value + }, toggle(id: string, meta: Meta & { value: boolean }) { const knob = register({ kind: "toggle", @@ -244,7 +262,7 @@ function save() { knob.initial[index], ]) - if (knob.kind === "toggle") return [[knob.id, Number(knob.value), Number(knob.initial)]] + if (knob.kind === "toggle" || knob.kind === "mode") return [[knob.id, Number(knob.value), Number(knob.initial)]] if (knob.kind === "choice") return [[knob.id, knob.options.indexOf(knob.value), knob.options.indexOf(knob.initial)]] From 4fac863072836f3e4041cd44a3c5a39703b8eef1 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 16:46:31 -0400 Subject: [PATCH 04/11] fix(app): make Low movement match the fade from #57880 --- packages/app/src/session/screen.tsx | 5 ++- packages/app/src/shell/panel-motion.ts | 42 +++++++++++-------- packages/app/src/shell/shell.tsx | 3 ++ .../app/test-browser/panel-motion.test.ts | 12 +++--- 4 files changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index c68199e857dd..a99bd423e772 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -491,10 +491,13 @@ function SessionScreenContent(props: { data-slot="session-side-panel-presence" class="z-0 min-w-0 h-full overflow-visible" classList={{ - "relative flex-1": sideMotion.present(), + "relative flex-1": sideMotion.present() && !sideMotion.leaving(), + // A low-movement close fades the panel out over the widened chat, at its open width. + "absolute inset-y-0 end-0 z-20": sideMotion.leaving(), // Out of the row, so a closed panel takes no space or gap. "absolute inset-y-0 end-0 w-0 [content-visibility:hidden]": !sideMotion.present(), }} + style={{ width: sideMotion.leaving() ? screen.side.contentWidth() : undefined }} >
HTMLElement | null | undefined }) { const [present, setPresent] = createSignal(input.open()) + // A low-movement close in flight: the layout has closed, and the panel fades out over it at its open size. + const [leaving, setLeaving] = createSignal(false) // Percent closed, so the spring's rest threshold stays well under a pixel. const motion = new Spring(input.open() ? 0 : 100) let stop: (() => void) | undefined @@ -155,16 +157,19 @@ export function createPanelMotion(input: { const settle = () => { stop?.() stop = undefined - fades.forEach((animation) => animation.cancel()) - fades = [] fly(false) const queued = landed landed = [] - if (!input.open()) { - setPresent(false) - input.onClosed?.() - } + batch(() => { + if (!input.open()) setPresent(false) + setLeaving(false) + }) + // After the panel is hidden, so dropping the animations' held end state never shows it at full opacity. + fades.forEach((animation) => animation.cancel()) + fades = [] + + if (!input.open()) input.onClosed?.() queued.forEach((run) => run()) } @@ -192,6 +197,7 @@ export function createPanelMotion(input: { stop = undefined fades.forEach((animation) => animation.cancel()) fades = [] + setLeaving(false) fly(false) motion.snap(target) paint() @@ -228,16 +234,17 @@ export function createPanelMotion(input: { ) /** - * Opens with the layout first, then the panel shifts in and fades up; closes in reverse, with the layout last. A - * reversal mid-flight turns around from where the panel is. + * The layout moves at once; the panel then shifts in and fades up, or on a close shifts out and fades down over the + * closed layout at its open size before it hides. A reversal mid-flight turns around from where the panel is. */ const fade = (open: boolean) => { stop?.() stop = undefined - glide(true, () => { - motion.snap(0) + glide(open, () => { + motion.snap(open ? 0 : 100) paint() }) + setLeaving(!open) fly(true) // Deferred so a panel that mounts with this open exists before it animates; microtasks run before the next paint. queueMicrotask(() => { @@ -245,7 +252,7 @@ export function createPanelMotion(input: { if (input.open() !== open) return - if (!element?.animate) return open ? settle() : snap(false) + if (!element?.animate) return settle() const x = (input.offset ?? 0) * (element.matches(":dir(rtl)") ? -1 : 1) const away = { transform: `translateX(${x}px)`, opacity: `${faded}` } @@ -263,7 +270,7 @@ export function createPanelMotion(input: { ] const current = fades void Promise.all(current.map((animation) => animation.finished)).then( - () => current === fades && (open ? settle() : glide(false, () => snap(false))), + () => current === fades && settle(), () => undefined, ) }) @@ -326,6 +333,7 @@ export function createPanelMotion(input: { return { present, + leaving, sync: (open: boolean) => { if (flying && input.open() === open) return snap(open) snapTo = open @@ -368,13 +376,13 @@ export function guardClosingFocus(input: { }) } -/** A spring sampled per 60 Hz frame into a CSS `linear()` easing, ending once within 0.5% of rest. */ +/** A spring sampled per 60 Hz frame into a CSS `linear()` easing. It ends once within 0.5% of rest, a twentieth of a + * pixel on these shifts. */ function springEasing(tuning: DurationOptions) { const spring = Spring.fromDuration(0, tuning).set(1) const points = [0] - while (points.length < 240 && (Math.abs(1 - points.at(-1)!) > 0.005 || Math.abs(spring.velocity) > 0.05)) - points.push(Math.round(spring.step(1 / 60) * 1000) / 1000) + while (points.length < 240 && points.at(-1)! < 0.995) points.push(Math.round(spring.step(1 / 60) * 1000) / 1000) points[points.length - 1] = 1 return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` } diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index f9a8f17a5c56..723bc9f5f00e 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -135,6 +135,8 @@ export default function Layout(props: ParentProps) { ref={(element) => setState("tabsPanel", element)} class="absolute inset-0 overflow-hidden [contain:layout_style_paint]" classList={{ "[content-visibility:hidden]": !sidebarMotion.present() }} + // A low-movement close keeps the open width over main while it fades out (the start edge holds). + style={{ width: sidebarMotion.leaving() ? `${state.tabsWidth}px` : undefined }} >
setState("tabsMount", element)} @@ -149,6 +151,7 @@ export default function Layout(props: ParentProps) {
{ +it("Low movement closes in reverse: the layout closes at once and the panel fades out over it, then hides", async () => { const panel = setupLow(true) panel.setOpen(false) + expect(panel.painted.width).toBe(0) + expect(panel.motion.present()).toBe(true) + expect(panel.motion.leaving()).toBe(true) await tick() const [shift, fade] = panel.played expect(shift.keyframes.transform).toEqual(["none", "translateX(-10px)"]) expect(fade.keyframes.opacity).toEqual(["1", "0.15"]) - expect(panel.painted.width).toBe(200) - expect(panel.motion.present()).toBe(true) await land(panel.played) - expect(panel.painted.width).toBe(0) expect(panel.motion.present()).toBe(false) + expect(panel.motion.leaving()).toBe(false) expect(panel.closed).toHaveLength(1) }) @@ -381,7 +382,8 @@ it("swaps Kit's knobs for Low movement's while its mode is on", () => { expect(shown()).toEqual(["Spring", "Motion"]) setLow(true) - expect(shown()).toEqual(["Movement", "Fade", "Fade duration", "Layout", "Layout open", "Layout close"]) + // Layout ships Off, which hides its two springs. + expect(shown()).toEqual(["Movement", "Fade", "Fade duration", "Layout"]) setLow(false) expect(shown()).toEqual(["Spring", "Motion"]) }) From 155dd7f8e43746d5573395f279fb5d081ad8652d Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 16:54:38 -0400 Subject: [PATCH 05/11] fix(app): Low movement closes instantly and glides its neighbours like the layout experiment --- packages/app/src/session/screen.tsx | 5 +- packages/app/src/shell/panel-motion.ts | 90 +++++++++---------- packages/app/src/shell/shell.tsx | 3 - .../app/test-browser/panel-motion.test.ts | 40 +++------ 4 files changed, 51 insertions(+), 87 deletions(-) diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index a99bd423e772..c68199e857dd 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -491,13 +491,10 @@ function SessionScreenContent(props: { data-slot="session-side-panel-presence" class="z-0 min-w-0 h-full overflow-visible" classList={{ - "relative flex-1": sideMotion.present() && !sideMotion.leaving(), - // A low-movement close fades the panel out over the widened chat, at its open width. - "absolute inset-y-0 end-0 z-20": sideMotion.leaving(), + "relative flex-1": sideMotion.present(), // Out of the row, so a closed panel takes no space or gap. "absolute inset-y-0 end-0 w-0 [content-visibility:hidden]": !sideMotion.present(), }} - style={{ width: sideMotion.leaving() ? screen.side.contentWidth() : undefined }} >
HTMLElement | null | undefined }) { const [present, setPresent] = createSignal(input.open()) - // A low-movement close in flight: the layout has closed, and the panel fades out over it at its open size. - const [leaving, setLeaving] = createSignal(false) // Percent closed, so the spring's rest threshold stays well under a pixel. const motion = new Spring(input.open() ? 0 : 100) let stop: (() => void) | undefined @@ -161,11 +159,7 @@ export function createPanelMotion(input: { const queued = landed landed = [] - batch(() => { - if (!input.open()) setPresent(false) - setLeaving(false) - }) - // After the panel is hidden, so dropping the animations' held end state never shows it at full opacity. + if (!input.open()) setPresent(false) fades.forEach((animation) => animation.cancel()) fades = [] @@ -197,7 +191,6 @@ export function createPanelMotion(input: { stop = undefined fades.forEach((animation) => animation.cancel()) fades = [] - setLeaving(false) fly(false) motion.snap(target) paint() @@ -209,7 +202,8 @@ export function createPanelMotion(input: { if (now || prefersReducedMotion() || (!low() && style() === "instant")) return snap(open) - if (low()) return fade(open) + // Low movement closes at once; only its neighbours glide into place. + if (low()) return open ? fade() : glide(false, () => snap(false)) const tuning = spring() motion.omega = (2 * Math.PI) / tuning.duration @@ -233,39 +227,30 @@ export function createPanelMotion(input: { ), ) - /** - * The layout moves at once; the panel then shifts in and fades up, or on a close shifts out and fades down over the - * closed layout at its open size before it hides. A reversal mid-flight turns around from where the panel is. - */ - const fade = (open: boolean) => { + /** The layout opens at once, gliding its neighbours into place; the panel then shifts in and fades up. */ + const fade = () => { stop?.() stop = undefined - glide(open, () => { - motion.snap(open ? 0 : 100) + glide(true, () => { + motion.snap(0) paint() }) - setLeaving(!open) fly(true) // Deferred so a panel that mounts with this open exists before it animates; microtasks run before the next paint. queueMicrotask(() => { const element = input.element?.() - if (input.open() !== open) return + if (!input.open()) return if (!element?.animate) return settle() const x = (input.offset ?? 0) * (element.matches(":dir(rtl)") ? -1 : 1) - const away = { transform: `translateX(${x}px)`, opacity: `${faded}` } - const rest = { transform: "none", opacity: "1" } - const target = open ? rest : away - const start = fades.length > 0 ? getComputedStyle(element) : open ? away : rest - const tuning = lowShift() fades.forEach((animation) => animation.cancel()) fades = [ - element.animate({ transform: [start.transform, target.transform] }, { ...springEasing(tuning), fill: "both" }), + element.animate({ transform: [`translateX(${x}px)`, "none"] }, springEasing(lowShift())), element.animate( - { opacity: [start.opacity, target.opacity] }, - { duration: lowFadeDuration() * 1000, easing: `cubic-bezier(${lowFade().join(", ")})`, fill: "both" }, + { opacity: [faded, 1] }, + { duration: lowFadeDuration() * 1000, easing: `cubic-bezier(${lowFade().join(", ")})` }, ), ] const current = fades @@ -277,8 +262,8 @@ export function createPanelMotion(input: { } /** - * Runs `change`, which moves the layout, then glides the neighbours from their old boxes to the new ones. The new - * boxes are read in a ResizeObserver pass, after the screen's own observers have settled their sizes for the change. + * Runs `change`, which moves the layout, then glides the neighbours from their old boxes to the new ones, measured + * on either side of the change and started in the same frame. */ const glide = (open: boolean, change: () => void) => { const mode = lowLayout() @@ -296,30 +281,19 @@ export function createPanelMotion(input: { change() if (before.length === 0 && !box) return - const timing = springEasing(open ? lowLayoutOpen() : lowLayoutClose()) + const timing = layoutEasing(open ? lowLayoutOpen() : lowLayoutClose()) - const observer = new ResizeObserver(() => { - observer.disconnect() - before.forEach(({ element, anchor, x }) => { - const dx = x - anchorX(element, anchor) - - if (Math.abs(dx) > 0.5) - element.animate( - { transform: [`translateX(${dx}px)`, "none"] }, - { ...timing, composite: "add", fill: "backwards" }, - ) - }) + before.forEach(({ element, anchor, x }) => { + const dx = x - anchorX(element, anchor) - if (!box || width === undefined) return - const to = box.getBoundingClientRect().width - - if (Math.abs(to - width) > 0.5) - box.animate({ width: [`${width}px`, `${to}px`] }, { ...timing, fill: "backwards" }) + if (Math.abs(dx) > 0.5) + element.animate({ transform: [`translateX(${dx}px)`, "none"] }, { ...timing, composite: "add" }) }) - before.forEach(({ element }) => observer.observe(element)) + if (!box || width === undefined) return + const to = box.getBoundingClientRect().width - if (box) observer.observe(box) + if (Math.abs(to - width) > 0.5) box.animate({ width: [`${width}px`, `${to}px`] }, timing) } // Repaints at rest when the open size changes, e.g. a resize drag or a window resize. @@ -333,7 +307,6 @@ export function createPanelMotion(input: { return { present, - leaving, sync: (open: boolean) => { if (flying && input.open() === open) return snap(open) snapTo = open @@ -388,6 +361,23 @@ function springEasing(tuning: DurationOptions) { return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` } } +/** A layout spring sampled per 60 Hz frame into `linear()`, until it rests within 0.5% and nearly still. */ +function layoutEasing(tuning: DurationOptions) { + const spring = Spring.fromDuration(0, tuning).set(1) + const points = [0] + + while (points.length < 240) { + const y = Math.round(spring.step(1 / 60) * 1000) / 1000 + points.push(y) + + if (Math.abs(1 - y) < 0.005 && Math.abs(spring.velocity) < 0.05) break + } + + points[points.length - 1] = 1 + + return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` } +} + function anchorX(element: HTMLElement, anchor: Anchor) { const box = element.getBoundingClientRect() diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 723bc9f5f00e..f9a8f17a5c56 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -135,8 +135,6 @@ export default function Layout(props: ParentProps) { ref={(element) => setState("tabsPanel", element)} class="absolute inset-0 overflow-hidden [contain:layout_style_paint]" classList={{ "[content-visibility:hidden]": !sidebarMotion.present() }} - // A low-movement close keeps the open width over main while it fades out (the start edge holds). - style={{ width: sidebarMotion.leaving() ? `${state.tabsWidth}px` : undefined }} >
setState("tabsMount", element)} @@ -151,7 +149,6 @@ export default function Layout(props: ParentProps) {
{ - const panel = setupLow(true) - panel.setOpen(false) - expect(panel.painted.width).toBe(0) - expect(panel.motion.present()).toBe(true) - expect(panel.motion.leaving()).toBe(true) +it("Low movement closes at once, cancelling an opening in flight", async () => { + const panel = setupLow(false) + panel.setOpen(true) await tick() + panel.setOpen(false) - const [shift, fade] = panel.played - expect(shift.keyframes.transform).toEqual(["none", "translateX(-10px)"]) - expect(fade.keyframes.opacity).toEqual(["1", "0.15"]) - - await land(panel.played) + expect(panel.painted.width).toBe(0) expect(panel.motion.present()).toBe(false) - expect(panel.motion.leaving()).toBe(false) expect(panel.closed).toHaveLength(1) -}) - -it("Low movement turns around mid-flight, cancelling the first direction", async () => { - const panel = setupLow(true) - panel.setOpen(false) - await tick() - panel.setOpen(true) - await tick() - - expect(panel.played).toHaveLength(4) - expect(panel.played.slice(0, 2).every((animation) => animation.cancelled)).toBe(true) - expect(panel.played[2].keyframes.transform?.at(-1)).toBe("none") - await land(panel.played) - expect(panel.motion.present()).toBe(true) - expect(panel.closed).toEqual([]) + expect(panel.played).toHaveLength(2) + expect(panel.played.every((animation) => animation.cancelled)).toBe(true) + expect(panelsMoving()).toBe(false) }) it("Low movement snaps under reduced motion", () => { @@ -382,8 +363,7 @@ it("swaps Kit's knobs for Low movement's while its mode is on", () => { expect(shown()).toEqual(["Spring", "Motion"]) setLow(true) - // Layout ships Off, which hides its two springs. - expect(shown()).toEqual(["Movement", "Fade", "Fade duration", "Layout"]) + expect(shown()).toEqual(["Movement", "Fade", "Fade duration", "Layout", "Layout open", "Layout close"]) setLow(false) expect(shown()).toEqual(["Spring", "Motion"]) }) From d020d0d27e955dc1b49a0d6d9aa22361d43fa17c Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 17:16:57 -0400 Subject: [PATCH 06/11] feat(app): tune the side panel's Low movement shift in Knobs --- packages/app/src/session/screen.tsx | 4 ++-- packages/app/src/shell/panel-motion.ts | 16 ++++++++++++++-- packages/app/src/shell/shell.tsx | 2 +- packages/app/test-browser/panel-motion.test.ts | 4 ++-- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index c68199e857dd..ed07abb47191 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -47,7 +47,7 @@ import { usePlatform } from "@/runtime/platform/platform" import { useServer } from "@/runtime/server/current" import { reviewGlyphFill, SessionReviewToggle } from "./header/session-header-actions" import { createTimelineCache } from "./timeline/cache" -import { createPanelMotion, replayPanelToggle } from "@/shell/panel-motion" +import { createPanelMotion, lowPanelShift, replayPanelToggle } from "@/shell/panel-motion" export function SessionScreenView(props: { session: SessionModel }) { // The timeline cache captures its owner when created, so link handling must be provided above it. @@ -184,7 +184,7 @@ function SessionScreenContent(props: { chat.style.width = closed === 0 ? open : `calc(${open} + (100% - ${open}) * ${closed})` }, element: () => sidePresence, - offset: 12, + offset: lowPanelShift, neighbours: () => [[chat, "center"]], resize: () => chat, }) diff --git a/packages/app/src/shell/panel-motion.ts b/packages/app/src/shell/panel-motion.ts index b20c61e94f1e..31f277105a2a 100644 --- a/packages/app/src/shell/panel-motion.ts +++ b/packages/app/src/shell/panel-motion.ts @@ -69,6 +69,18 @@ const lowFadeDuration = motionTuning.number("sidebars.low.fadeDuration", { time: "duration", }) +/** How far the right side panel shifts in from its edge on a low-movement open, in pixels. */ +export const lowPanelShift = motionTuning.number("sidebars.low.panelShift", { + label: "Panel shift", + group, + when: low, + value: 12, + min: 0, + max: 48, + step: 1, + unit: "px", +}) + // "flip" translates the neighbours from their old box on the compositor; "width" animates the resized box's real width, // re-wrapping its content each frame. const lowLayout = motionTuning.choice("sidebars.low.layout", { @@ -127,7 +139,7 @@ export function createPanelMotion(input: { * the inline-start edge; mirrored in RTL). */ element?: Accessor - offset?: number + offset?: () => number /** Low movement: the boxes beside the panel that glide into place when its layout changes, and how. */ neighbours?: () => Array<[HTMLElement | null | undefined, Anchor]> /** Low movement, width layout: the box whose real width animates. */ @@ -244,7 +256,7 @@ export function createPanelMotion(input: { if (!element?.animate) return settle() - const x = (input.offset ?? 0) * (element.matches(":dir(rtl)") ? -1 : 1) + const x = (input.offset?.() ?? 0) * (element.matches(":dir(rtl)") ? -1 : 1) fades.forEach((animation) => animation.cancel()) fades = [ element.animate({ transform: [`translateX(${x}px)`, "none"] }, springEasing(lowShift())), diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index f9a8f17a5c56..bdef172cc1fb 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -55,7 +55,7 @@ export default function Layout(props: ParentProps) { }, measure: () => measureSidebarToggles(state.tabsWidth), element: () => state.tabsPanel, - offset: -10, + offset: () => -10, neighbours: () => [ [document.querySelector('[data-slot="session-chat-panel"]'), "center"], [document.querySelector('[data-slot="session-side-panel-presence"]'), "start"], diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts index c2e5feb07a3f..e0a30bbec7f1 100644 --- a/packages/app/test-browser/panel-motion.test.ts +++ b/packages/app/test-browser/panel-motion.test.ts @@ -285,7 +285,7 @@ function setupLow(initial: boolean) { }, onClosed: () => closed.push(time), element: () => element, - offset: -10, + offset: () => -10, }) }) @@ -363,7 +363,7 @@ it("swaps Kit's knobs for Low movement's while its mode is on", () => { expect(shown()).toEqual(["Spring", "Motion"]) setLow(true) - expect(shown()).toEqual(["Movement", "Fade", "Fade duration", "Layout", "Layout open", "Layout close"]) + expect(shown()).toEqual(["Movement", "Fade", "Fade duration", "Panel shift", "Layout", "Layout open", "Layout close"]) setLow(false) expect(shown()).toEqual(["Spring", "Motion"]) }) From 25cf1eb8a938ba679e39a8f23d30f8166db920a2 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 17:20:56 -0400 Subject: [PATCH 07/11] feat(app): rename the Low movement sidebar option to James --- packages/app/src/shell/panel-motion.ts | 18 +++++++++--------- packages/app/test-browser/panel-motion.test.ts | 12 ++++++------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/app/src/shell/panel-motion.ts b/packages/app/src/shell/panel-motion.ts index 31f277105a2a..549ad19ce8f8 100644 --- a/packages/app/src/shell/panel-motion.ts +++ b/packages/app/src/shell/panel-motion.ts @@ -9,11 +9,11 @@ const [flights, setFlights] = createSignal(0) export const panelsMoving = () => flights() > 0 -// Low movement, chosen in the preset strip beside Snappy, Smooth and Calm, swaps the section's knobs for its own: layout +// The "James" option, chosen in the preset strip beside Snappy, Smooth and Calm, swaps the section's knobs for its own: layout // changes in one frame and only the panel moves, a few pixels toward its edge, while it fades. The neighbours beside it // glide from their old box to the new one. All run as Web Animations, so the compositor plays them through main-thread // work. -const low = motionTuning.mode("sidebars.low", { label: "Low movement", group }) +const low = motionTuning.mode("sidebars.low", { label: "James", group }) const kit = () => !low() @@ -69,7 +69,7 @@ const lowFadeDuration = motionTuning.number("sidebars.low.fadeDuration", { time: "duration", }) -/** How far the right side panel shifts in from its edge on a low-movement open, in pixels. */ +/** How far the right side panel shifts in from its edge when the James option opens it, in pixels. */ export const lowPanelShift = motionTuning.number("sidebars.low.panelShift", { label: "Panel shift", group, @@ -108,7 +108,7 @@ const lowLayoutOpen = layoutSpring("open", "Layout open") const lowLayoutClose = layoutSpring("close", "Layout close") -// The opacity a low-movement panel fades up from and down to. +// The opacity a panel fades up from under the James option and down to. const faded = 0.15 /** Where a neighbour's content sits: centred content follows its box's centre, start-pinned content its start edge. */ @@ -135,14 +135,14 @@ export function createPanelMotion(input: { /** Reads layout in the frame loop's read phase, before each frame of a flight paints. */ measure?: () => void /** - * Low movement: the panel layer that shifts and fades, and how far it shifts, in pixels toward its edge (negative for + * James option: the panel layer that shifts and fades, and how far it shifts, in pixels toward its edge (negative for * the inline-start edge; mirrored in RTL). */ element?: Accessor offset?: () => number - /** Low movement: the boxes beside the panel that glide into place when its layout changes, and how. */ + /** James option: the boxes beside the panel that glide into place when its layout changes, and how. */ neighbours?: () => Array<[HTMLElement | null | undefined, Anchor]> - /** Low movement, width layout: the box whose real width animates. */ + /** James option, width layout: the box whose real width animates. */ resize?: () => HTMLElement | null | undefined }) { const [present, setPresent] = createSignal(input.open()) @@ -153,7 +153,7 @@ export function createPanelMotion(input: { // A state `sync` asked to show at once, for when the change hasn't reached this motion yet, e.g. inside a batch. let snapTo: boolean | undefined let flying = false - // The low-movement flight in progress, if any. + // The James option's flight in progress, if any. let fades: Animation[] = [] const fly = (next: boolean) => { @@ -214,7 +214,7 @@ export function createPanelMotion(input: { if (now || prefersReducedMotion() || (!low() && style() === "instant")) return snap(open) - // Low movement closes at once; only its neighbours glide into place. + // The James option closes at once; only its neighbours glide into place. if (low()) return open ? fade() : glide(false, () => snap(false)) const tuning = spring() diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts index e0a30bbec7f1..aa5d4aa99a4b 100644 --- a/packages/app/test-browser/panel-motion.test.ts +++ b/packages/app/test-browser/panel-motion.test.ts @@ -42,7 +42,7 @@ function setStyle(value: string) { if (knob?.kind === "choice") motionTuning.set(knob, value) } -// Low movement is a mode, chosen in the preset strip rather than the Motion choice. +// The James option is a mode, chosen in the preset strip rather than the Motion choice. function setLow(on: boolean) { const knob = motionTuning.list().find((item) => item.id === "sidebars.low") @@ -251,7 +251,7 @@ type Played = { finish: () => void } -// happy-dom has no Web Animations, so the panel layer records what Low movement asks it to play. +// happy-dom has no Web Animations, so the panel layer records what the James option asks it to play. function setupLow(initial: boolean) { setLow(true) const [open, setOpen] = createSignal(initial) @@ -302,7 +302,7 @@ const land = async (played: Played[]) => { await tick() } -it("Low movement opens with the layout first, then shifts the panel in on a spring and fades it up from 0.15", async () => { +it("The James option opens with the layout first, then shifts the panel in on a spring and fades it up from 0.15", async () => { const panel = setupLow(false) panel.setOpen(true) expect(panel.painted.width).toBe(200) @@ -329,7 +329,7 @@ it("Low movement opens with the layout first, then shifts the panel in on a spri expect(panelsMoving()).toBe(false) }) -it("Low movement closes at once, cancelling an opening in flight", async () => { +it("The James option closes at once, cancelling an opening in flight", async () => { const panel = setupLow(false) panel.setOpen(true) await tick() @@ -343,7 +343,7 @@ it("Low movement closes at once, cancelling an opening in flight", async () => { expect(panelsMoving()).toBe(false) }) -it("Low movement snaps under reduced motion", () => { +it("The James option snaps under reduced motion", () => { document.documentElement.dataset.motion = "reduce" cleanups.push(() => delete document.documentElement.dataset.motion) const panel = setupLow(true) @@ -354,7 +354,7 @@ it("Low movement snaps under reduced motion", () => { expect(panel.motion.present()).toBe(false) }) -it("swaps Kit's knobs for Low movement's while its mode is on", () => { +it("swaps Kit's knobs for the James option's while its mode is on", () => { const shown = () => motionTuning .list() From 3103a7ee0f987a14a02cd3cf0e276c8db27f43a7 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 17:22:55 -0400 Subject: [PATCH 08/11] feat(ui): show a Knobs mode only on its own section's tab --- packages/ui/src/motion-tuning-panel.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts index 9106425c6781..7b780e9fadbf 100644 --- a/packages/ui/src/motion-tuning-panel.ts +++ b/packages/ui/src/motion-tuning-panel.ts @@ -80,8 +80,7 @@ export function mountMotionPanel( unmount?.() const all = shown() // Modes sit in the preset strip, not as rows. While one is on, the spring presets only switch back, then apply. - const modes = all.flatMap((item) => (item.kind === "mode" ? [item] : [])) - const active = modes.find((item) => item.value) + const active = all.find((item) => item.kind === "mode" && item.value) const list = all.filter((item) => item.kind !== "mode") const actions = motionTuning.actions() const mounted = identity() @@ -91,6 +90,11 @@ export function mountMotionPanel( openEditors(list.flatMap((item) => (item.kind === "easing" ? [item.id] : []))) const sections = [...new Set([...list, ...actions].map((item) => item.group))].filter((group) => group !== global) + // A mode sits in the strip only on its own section's tab; switching to or from that tab rebuilds the strip. + const modes = all.flatMap((item) => + item.kind === "mode" && item.group === (section ?? sections[0]) ? [item] : [], + ) + const controls = defineControls( "opencode-motion", { @@ -138,7 +142,7 @@ export function mountMotionPanel( sync() // The strip shows the mode that's on; a preset chosen while a mode was on applies once its knobs are back. - if (active) controls.preset(active.id) + if (active && active.id in controls.presets) controls.preset(active.id) if (!active && switching && switching in controls.presets) controls.preset(switching) switching = undefined @@ -165,7 +169,15 @@ export function mountMotionPanel( hints: "panel", tabs: sections, tab: section, - onTab: (id) => (section = id), + onTab: (id) => { + const offered = (group: string | undefined) => + all.some((item) => item.kind === "mode" && item.group === group) + + const rebuild = offered(section) !== offered(id) + section = id + + if (rebuild) mount() + }, }) const stopWatch = motionTuning.subscribe(() => { From 01ae89f5e0f27103b01520023032773d9e4e8d34 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 17:30:34 -0400 Subject: [PATCH 09/11] feat(app): ship the James option as the default sidebar motion, with a 30px panel shift --- packages/app/e2e/regression/panel-edges.spec.ts | 3 ++- packages/app/src/shell/panel-motion.ts | 5 +++-- packages/app/test-browser/panel-motion.test.ts | 11 +++++++++++ packages/ui/src/motion-tuning.ts | 8 +++++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/app/e2e/regression/panel-edges.spec.ts b/packages/app/e2e/regression/panel-edges.spec.ts index 92d8263dda12..9a0bf3a51535 100644 --- a/packages/app/e2e/regression/panel-edges.spec.ts +++ b/packages/app/e2e/regression/panel-edges.spec.ts @@ -43,7 +43,8 @@ test("the content beside a panel follows the panel's edge on every frame of a to // Half speed, so even a slow runner draws several frames of each flight. await seed(page, { settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } }, - storage: { "opencode.motion.v2": { speed: 0.5 } }, + // Kit's spring, which this spec covers; the James option ships as the default. + storage: { "opencode.motion.v2": { speed: 0.5, "sidebars.low": 0 } }, }) await page.goto(sessionHref(fixture.targetID)) await expect(page.getByRole("heading", { name: fixture.expected.targetTitle })).toBeVisible() diff --git a/packages/app/src/shell/panel-motion.ts b/packages/app/src/shell/panel-motion.ts index 549ad19ce8f8..d1d7a28b7d05 100644 --- a/packages/app/src/shell/panel-motion.ts +++ b/packages/app/src/shell/panel-motion.ts @@ -13,7 +13,8 @@ export const panelsMoving = () => flights() > 0 // changes in one frame and only the panel moves, a few pixels toward its edge, while it fades. The neighbours beside it // glide from their old box to the new one. All run as Web Animations, so the compositor plays them through main-thread // work. -const low = motionTuning.mode("sidebars.low", { label: "James", group }) +// The shipped sidebar motion; Kit's spring presets and Instant stay one choice away. +const low = motionTuning.mode("sidebars.low", { label: "James", group, value: true }) const kit = () => !low() @@ -74,7 +75,7 @@ export const lowPanelShift = motionTuning.number("sidebars.low.panelShift", { label: "Panel shift", group, when: low, - value: 12, + value: 30, min: 0, max: 48, step: 1, diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts index aa5d4aa99a4b..8f36d283dc74 100644 --- a/packages/app/test-browser/panel-motion.test.ts +++ b/packages/app/test-browser/panel-motion.test.ts @@ -14,6 +14,8 @@ const cleanups: (() => void)[] = [] const originals = { raf: globalThis.requestAnimationFrame, caf: globalThis.cancelAnimationFrame } beforeEach(() => { + // Kit's spring tests run with the James option off; its own tests turn it on. + setLow(false) frames = new Map() id = 0 time = 0 @@ -367,3 +369,12 @@ it("swaps Kit's knobs for the James option's while its mode is on", () => { setLow(false) expect(shown()).toEqual(["Spring", "Motion"]) }) + +it("ships the James option as the default sidebar motion", () => { + const knob = motionTuning.list().find((item) => item.id === "sidebars.low") + + expect(knob?.kind).toBe("mode") + expect(knob?.initial).toBe(true) + // The side panel shifts 30px by default; the left sidebar stays at 10px. + expect(motionTuning.list().find((item) => item.id === "sidebars.low.panelShift")?.initial).toBe(30) +}) diff --git a/packages/ui/src/motion-tuning.ts b/packages/ui/src/motion-tuning.ts index 93fa5d98af53..12446a500c6b 100644 --- a/packages/ui/src/motion-tuning.ts +++ b/packages/ui/src/motion-tuning.ts @@ -87,15 +87,17 @@ export const motionTuning = { * An alternative to a section's springs, offered beside the Snappy, Smooth and Calm presets rather than as a row. * Choosing it turns it on; choosing any other preset turns it off. Knobs can show only in one or the other with `when`. */ - mode(id: string, meta: Meta) { + mode(id: string, meta: Meta & { value?: boolean }) { + const initial = meta.value ?? false + const knob = register({ kind: "mode", id, label: meta.label, group: meta.group, when: meta.when, - initial: false, - value: number(id, 0) === 1, + initial, + value: number(id, initial ? 1 : 0) === 1, }) return () => knob.value From 31bc0592d5ebd56160077306683daad12d20b090 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 17:35:49 -0400 Subject: [PATCH 10/11] fix(ui): keep spring presets on other Knobs tabs while a mode is on --- packages/ui/src/motion-tuning-panel.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts index 7b780e9fadbf..f785a20ff793 100644 --- a/packages/ui/src/motion-tuning-panel.ts +++ b/packages/ui/src/motion-tuning-panel.ts @@ -79,12 +79,13 @@ export function mountMotionPanel( const mount = () => { unmount?.() const all = shown() - // Modes sit in the preset strip, not as rows. While one is on, the spring presets only switch back, then apply. + // Modes sit in the preset strip, not as rows. While one is on, the spring presets leave its section's springs alone + // (choosing one on that section's tab switches back to them first); other sections' springs take presets as usual. const active = all.find((item) => item.kind === "mode" && item.value) const list = all.filter((item) => item.kind !== "mode") const actions = motionTuning.actions() const mounted = identity() - const springs = active ? [] : list.flatMap((item) => (item.kind === "spring" ? [item] : [])) + const springs = list.flatMap((item) => (item.kind === "spring" && item.group !== active?.group ? [item] : [])) const preset = (id: MotionPreset) => Object.fromEntries(springs.map((item) => [item.id, item.presets[id]])) // Easing editors open inline, like the spring rows beside them, rather than folded behind a chevron. openEditors(list.flatMap((item) => (item.kind === "easing" ? [item.id] : []))) From 66e6c8e5de574a3c6012bf09846821ce2c2abea4 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 18:19:24 -0400 Subject: [PATCH 11/11] refactor(app): name the James sidebar motion consistently --- .../app/e2e/regression/panel-edges.spec.ts | 3 +- packages/app/src/session/screen.tsx | 4 +- packages/app/src/shell/panel-motion.ts | 64 +++++++++---------- .../app/test-browser/panel-motion.test.ts | 42 ++++++------ 4 files changed, 56 insertions(+), 57 deletions(-) diff --git a/packages/app/e2e/regression/panel-edges.spec.ts b/packages/app/e2e/regression/panel-edges.spec.ts index 9a0bf3a51535..0793e6137e99 100644 --- a/packages/app/e2e/regression/panel-edges.spec.ts +++ b/packages/app/e2e/regression/panel-edges.spec.ts @@ -43,8 +43,7 @@ test("the content beside a panel follows the panel's edge on every frame of a to // Half speed, so even a slow runner draws several frames of each flight. await seed(page, { settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } }, - // Kit's spring, which this spec covers; the James option ships as the default. - storage: { "opencode.motion.v2": { speed: 0.5, "sidebars.low": 0 } }, + storage: { "opencode.motion.v2": { speed: 0.5, "sidebars.james": 0 } }, }) await page.goto(sessionHref(fixture.targetID)) await expect(page.getByRole("heading", { name: fixture.expected.targetTitle })).toBeVisible() diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index ed07abb47191..2eecf6e1fc01 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -47,7 +47,7 @@ import { usePlatform } from "@/runtime/platform/platform" import { useServer } from "@/runtime/server/current" import { reviewGlyphFill, SessionReviewToggle } from "./header/session-header-actions" import { createTimelineCache } from "./timeline/cache" -import { createPanelMotion, lowPanelShift, replayPanelToggle } from "@/shell/panel-motion" +import { createPanelMotion, jamesPanelShift, replayPanelToggle } from "@/shell/panel-motion" export function SessionScreenView(props: { session: SessionModel }) { // The timeline cache captures its owner when created, so link handling must be provided above it. @@ -184,7 +184,7 @@ function SessionScreenContent(props: { chat.style.width = closed === 0 ? open : `calc(${open} + (100% - ${open}) * ${closed})` }, element: () => sidePresence, - offset: lowPanelShift, + offset: jamesPanelShift, neighbours: () => [[chat, "center"]], resize: () => chat, }) diff --git a/packages/app/src/shell/panel-motion.ts b/packages/app/src/shell/panel-motion.ts index d1d7a28b7d05..201e0e850f6b 100644 --- a/packages/app/src/shell/panel-motion.ts +++ b/packages/app/src/shell/panel-motion.ts @@ -13,16 +13,16 @@ export const panelsMoving = () => flights() > 0 // changes in one frame and only the panel moves, a few pixels toward its edge, while it fades. The neighbours beside it // glide from their old box to the new one. All run as Web Animations, so the compositor plays them through main-thread // work. -// The shipped sidebar motion; Kit's spring presets and Instant stay one choice away. -const low = motionTuning.mode("sidebars.low", { label: "James", group, value: true }) +// The shipped sidebar motion; the spring presets and Instant stay one choice away. +const james = motionTuning.mode("sidebars.james", { label: "James", group, value: true }) -const kit = () => !low() +const springs = () => !james() // The left sidebar and the side panel share one spring, so they move as one system. const spring = motionTuning.spring("sidebars", { label: "Spring", group, - when: kit, + when: springs, shipped: "smooth", presets: { snappy: { duration: 0.2, bounce: 0 }, @@ -34,15 +34,15 @@ const spring = motionTuning.spring("sidebars", { const style = motionTuning.choice("sidebars.style", { label: "Motion", group, - when: kit, + when: springs, value: "spring", labels: { spring: "Spring", instant: "Instant" }, }) -const lowShift = motionTuning.spring("sidebars.low.shift", { +const jamesShift = motionTuning.spring("sidebars.james.shift", { label: "Movement", group, - when: low, + when: james, shipped: "smooth", presets: { snappy: { duration: 0.18, bounce: 0 }, @@ -51,17 +51,17 @@ const lowShift = motionTuning.spring("sidebars.low.shift", { }, }) -const lowFade = motionTuning.easing("sidebars.low.fade", { +const jamesFade = motionTuning.easing("sidebars.james.fade", { label: "Fade", group, - when: low, + when: james, value: [0.44, 0.75, 0.63, 1.03], }) -const lowFadeDuration = motionTuning.number("sidebars.low.fadeDuration", { +const jamesFadeDuration = motionTuning.number("sidebars.james.fadeDuration", { label: "Fade duration", group, - when: low, + when: james, value: 0.18, min: 0.05, max: 0.6, @@ -70,11 +70,11 @@ const lowFadeDuration = motionTuning.number("sidebars.low.fadeDuration", { time: "duration", }) -/** How far the right side panel shifts in from its edge when the James option opens it, in pixels. */ -export const lowPanelShift = motionTuning.number("sidebars.low.panelShift", { +/** How far the right side panel shifts in from its edge when James opens it, in pixels. */ +export const jamesPanelShift = motionTuning.number("sidebars.james.panelShift", { label: "Panel shift", group, - when: low, + when: james, value: 30, min: 0, max: 48, @@ -84,19 +84,19 @@ export const lowPanelShift = motionTuning.number("sidebars.low.panelShift", { // "flip" translates the neighbours from their old box on the compositor; "width" animates the resized box's real width, // re-wrapping its content each frame. -const lowLayout = motionTuning.choice("sidebars.low.layout", { +const jamesLayout = motionTuning.choice("sidebars.james.layout", { label: "Layout", group, - when: low, + when: james, value: "flip", labels: { flip: "Glide (FLIP)", width: "Width", off: "Off" }, }) const layoutSpring = (direction: string, label: string) => - motionTuning.spring(`sidebars.low.layout.${direction}`, { + motionTuning.spring(`sidebars.james.layout.${direction}`, { label, group, - when: () => low() && lowLayout() !== "off", + when: () => james() && jamesLayout() !== "off", shipped: "smooth", presets: { snappy: { duration: 0.12, bounce: 0 }, @@ -105,11 +105,11 @@ const layoutSpring = (direction: string, label: string) => }, }) -const lowLayoutOpen = layoutSpring("open", "Layout open") +const jamesLayoutOpen = layoutSpring("open", "Layout open") -const lowLayoutClose = layoutSpring("close", "Layout close") +const jamesLayoutClose = layoutSpring("close", "Layout close") -// The opacity a panel fades up from under the James option and down to. +// The opacity a panel fades up from under James and down to. const faded = 0.15 /** Where a neighbour's content sits: centred content follows its box's centre, start-pinned content its start edge. */ @@ -136,14 +136,14 @@ export function createPanelMotion(input: { /** Reads layout in the frame loop's read phase, before each frame of a flight paints. */ measure?: () => void /** - * James option: the panel layer that shifts and fades, and how far it shifts, in pixels toward its edge (negative for + * James: the panel layer that shifts and fades, and how far it shifts, in pixels toward its edge (negative for * the inline-start edge; mirrored in RTL). */ element?: Accessor offset?: () => number - /** James option: the boxes beside the panel that glide into place when its layout changes, and how. */ + /** James: the boxes beside the panel that glide into place when its layout changes, and how. */ neighbours?: () => Array<[HTMLElement | null | undefined, Anchor]> - /** James option, width layout: the box whose real width animates. */ + /** James, width layout: the box whose real width animates. */ resize?: () => HTMLElement | null | undefined }) { const [present, setPresent] = createSignal(input.open()) @@ -154,7 +154,7 @@ export function createPanelMotion(input: { // A state `sync` asked to show at once, for when the change hasn't reached this motion yet, e.g. inside a batch. let snapTo: boolean | undefined let flying = false - // The James option's flight in progress, if any. + // James's flight in progress, if any. let fades: Animation[] = [] const fly = (next: boolean) => { @@ -213,10 +213,10 @@ export function createPanelMotion(input: { return } - if (now || prefersReducedMotion() || (!low() && style() === "instant")) return snap(open) + if (now || prefersReducedMotion() || (!james() && style() === "instant")) return snap(open) - // The James option closes at once; only its neighbours glide into place. - if (low()) return open ? fade() : glide(false, () => snap(false)) + // James closes at once; only its neighbours glide into place. + if (james()) return open ? fade() : glide(false, () => snap(false)) const tuning = spring() motion.omega = (2 * Math.PI) / tuning.duration @@ -260,10 +260,10 @@ export function createPanelMotion(input: { const x = (input.offset?.() ?? 0) * (element.matches(":dir(rtl)") ? -1 : 1) fades.forEach((animation) => animation.cancel()) fades = [ - element.animate({ transform: [`translateX(${x}px)`, "none"] }, springEasing(lowShift())), + element.animate({ transform: [`translateX(${x}px)`, "none"] }, springEasing(jamesShift())), element.animate( { opacity: [faded, 1] }, - { duration: lowFadeDuration() * 1000, easing: `cubic-bezier(${lowFade().join(", ")})` }, + { duration: jamesFadeDuration() * 1000, easing: `cubic-bezier(${jamesFade().join(", ")})` }, ), ] const current = fades @@ -279,7 +279,7 @@ export function createPanelMotion(input: { * on either side of the change and started in the same frame. */ const glide = (open: boolean, change: () => void) => { - const mode = lowLayout() + const mode = jamesLayout() const before = mode === "flip" @@ -294,7 +294,7 @@ export function createPanelMotion(input: { change() if (before.length === 0 && !box) return - const timing = layoutEasing(open ? lowLayoutOpen() : lowLayoutClose()) + const timing = layoutEasing(open ? jamesLayoutOpen() : jamesLayoutClose()) before.forEach(({ element, anchor, x }) => { const dx = x - anchorX(element, anchor) diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts index 8f36d283dc74..dfb3422215fd 100644 --- a/packages/app/test-browser/panel-motion.test.ts +++ b/packages/app/test-browser/panel-motion.test.ts @@ -14,8 +14,8 @@ const cleanups: (() => void)[] = [] const originals = { raf: globalThis.requestAnimationFrame, caf: globalThis.cancelAnimationFrame } beforeEach(() => { - // Kit's spring tests run with the James option off; its own tests turn it on. - setLow(false) + // The spring tests run with James off; James's own tests turn it on. + setJames(false) frames = new Map() id = 0 time = 0 @@ -35,7 +35,7 @@ afterEach(() => { globalThis.requestAnimationFrame = originals.raf globalThis.cancelAnimationFrame = originals.caf setStyle("spring") - setLow(false) + setJames(false) }) function setStyle(value: string) { @@ -44,9 +44,9 @@ function setStyle(value: string) { if (knob?.kind === "choice") motionTuning.set(knob, value) } -// The James option is a mode, chosen in the preset strip rather than the Motion choice. -function setLow(on: boolean) { - const knob = motionTuning.list().find((item) => item.id === "sidebars.low") +// James is a mode, chosen in the preset strip rather than the Motion choice. +function setJames(on: boolean) { + const knob = motionTuning.list().find((item) => item.id === "sidebars.james") if (knob?.kind === "mode") motionTuning.set(knob, on) } @@ -253,9 +253,9 @@ type Played = { finish: () => void } -// happy-dom has no Web Animations, so the panel layer records what the James option asks it to play. -function setupLow(initial: boolean) { - setLow(true) +// happy-dom has no Web Animations, so the panel layer records what James asks it to play. +function setupJames(initial: boolean) { + setJames(true) const [open, setOpen] = createSignal(initial) const played: Played[] = [] const closed: number[] = [] @@ -304,8 +304,8 @@ const land = async (played: Played[]) => { await tick() } -it("The James option opens with the layout first, then shifts the panel in on a spring and fades it up from 0.15", async () => { - const panel = setupLow(false) +it("James opens with the layout first, then shifts the panel in on a spring and fades it up from 0.15", async () => { + const panel = setupJames(false) panel.setOpen(true) expect(panel.painted.width).toBe(200) expect(panel.motion.present()).toBe(true) @@ -331,8 +331,8 @@ it("The James option opens with the layout first, then shifts the panel in on a expect(panelsMoving()).toBe(false) }) -it("The James option closes at once, cancelling an opening in flight", async () => { - const panel = setupLow(false) +it("James closes at once, cancelling an opening in flight", async () => { + const panel = setupJames(false) panel.setOpen(true) await tick() panel.setOpen(false) @@ -345,10 +345,10 @@ it("The James option closes at once, cancelling an opening in flight", async () expect(panelsMoving()).toBe(false) }) -it("The James option snaps under reduced motion", () => { +it("James snaps under reduced motion", () => { document.documentElement.dataset.motion = "reduce" cleanups.push(() => delete document.documentElement.dataset.motion) - const panel = setupLow(true) + const panel = setupJames(true) panel.setOpen(false) expect(panel.played).toEqual([]) @@ -356,7 +356,7 @@ it("The James option snaps under reduced motion", () => { expect(panel.motion.present()).toBe(false) }) -it("swaps Kit's knobs for the James option's while its mode is on", () => { +it("swaps the spring knobs for James's while it's on", () => { const shown = () => motionTuning .list() @@ -364,17 +364,17 @@ it("swaps Kit's knobs for the James option's while its mode is on", () => { .map((knob) => knob.label) expect(shown()).toEqual(["Spring", "Motion"]) - setLow(true) + setJames(true) expect(shown()).toEqual(["Movement", "Fade", "Fade duration", "Panel shift", "Layout", "Layout open", "Layout close"]) - setLow(false) + setJames(false) expect(shown()).toEqual(["Spring", "Motion"]) }) -it("ships the James option as the default sidebar motion", () => { - const knob = motionTuning.list().find((item) => item.id === "sidebars.low") +it("ships James as the default sidebar motion", () => { + const knob = motionTuning.list().find((item) => item.id === "sidebars.james") expect(knob?.kind).toBe("mode") expect(knob?.initial).toBe(true) // The side panel shifts 30px by default; the left sidebar stays at 10px. - expect(motionTuning.list().find((item) => item.id === "sidebars.low.panelShift")?.initial).toBe(30) + expect(motionTuning.list().find((item) => item.id === "sidebars.james.panelShift")?.initial).toBe(30) })