feat(app): rebuild Knobs as a stack of features (#57921)

This commit is contained in:
Kit Langton
2026-10-24 21:52:22 -07:00
committed by GitHub
Unverified
parent 8d52b4e452
commit ce447693b1
23 changed files with 1319 additions and 378 deletions
@@ -51,3 +51,51 @@ story("the debug bar stays open across a remount until its close button hides it
expect(result).toEqual({ opened: true, saved: { visible: false, motion: false }, reopened: false })
})
story("a feature page's Enabled switch turns that feature on after another feature's page", async ({ page }) => {
const result = await page.evaluate(
async (modules) => {
localStorage.setItem("opencode.motion.panel", JSON.stringify({ x: 0, y: 0, width: 680 }))
const { motionTuning } = await import(modules.tuning)
const experiment = (id: string, subsection: string) =>
motionTuning.toggle(id, { label: subsection, group: "Switches", subsection, experiment: true, value: false })
const first = experiment("switches.first", "First")
const second = experiment("switches.second", "Second")
const { mountExtensions, until } = await import(modules.fixture)
const debug = await import(modules.debug)
const definition = { ...debug.default, renderer: () => import(modules.renderer) }
const host = mountExtensions({
definitions: [definition],
stored: { "extension.debug.bar": { visible: true, motion: true } },
})
host.release()
const row = (name: string) =>
document.querySelector<HTMLButtonElement>(`[data-motion-feature="Switches · ${name}"]`)
const enabled = (name: string) =>
[...document.querySelectorAll("h3")]
.find((heading) => heading.textContent === name)
?.parentElement?.querySelector<HTMLInputElement>('[data-component="switch"] input')
await until(() => row("First") !== null)
row("First")?.click()
await until(() => !!enabled("First"))
row("Second")?.click()
await until(() => !!enabled("Second"))
enabled("Second")?.click()
await until(() => first() || second())
const values = { first: first(), second: second() }
host.unmount()
return values
},
{ ...modules, tuning: sourceURL(new URL("../../ui/src/motion-tuning.ts", import.meta.url)) },
)
expect(result).toEqual({ first: false, second: true })
})
+1
View File
@@ -19,6 +19,7 @@ export const dict = {
"voice.failure.dropped": "The call disconnected.",
"voice.failure.connection": "Couldn’t connect the call.",
"experiment.activityChip": "Activity chip",
"experiment.activityChip.style": "Style",
"experiment.activityChip.today": "Today",
"experiment.activityChip.orb": "CSS orb",
"experiment.chipCorners": "Chip corners",
@@ -12,20 +12,27 @@ import { useSettings } from "@/settings/model"
import "./activity-chips.css"
const chip = motionTuning.choice("activity.chip", {
label: en["experiment.activityChip"],
label: en["experiment.activityChip.style"],
group: "Composer",
subsection: en["experiment.activityChip"],
description: "Chip above the composer",
target: '[data-component="session-activity-chips"]',
experiment: true,
value: "today",
labels: { today: en["experiment.activityChip.today"], orb: en["experiment.activityChip.orb"] },
})
const corners = motionTuning.choice("activity.chip.corners", {
label: en["experiment.chipCorners"],
group: "Composer",
subsection: en["experiment.activityChip"],
experiment: true,
value: "capsule",
labels: { capsule: en["experiment.chipCorners.capsule"], composer: en["experiment.chipCorners.composer"] },
})
const [experiment, setExperiment] = createStore({ chip: chip(), corners: corners() })
motionTuning.subscribe(() => setExperiment({ chip: chip(), corners: corners() }))
export type SessionActivityItem = {
@@ -73,16 +80,20 @@ export function SessionActivityChips(props: {
}) {
const language = useLanguage()
const settings = useSettings()
const active = () =>
settings.general.backgroundActivity() !== "off" && (props.subagents.length > 0 || props.shells.length > 0)
const alignment = createMemo(() => {
const value = settings.general.backgroundActivity()
if (value === "center") return "center"
// Left/right are physical positions; Kobalte and flex alignment use logical start/end.
return (value === "left") !== (language.direction() === "rtl") ? "start" : "end"
})
const placement = () => (alignment() === "center" ? "top" : alignment() === "end" ? "top-end" : "top-start")
const placement = () => ({ center: "top", start: "top-start", end: "top-end" } as const)[alignment()]
// A subagent can only be interrupted once its child session exists.
const stoppable = (item: SessionActivityItem) => item.type === "shell" || !!item.sessionID
@@ -200,6 +211,7 @@ export function SessionActivityChips(props: {
onFocusIn={(event: FocusEvent) => {
if (!(event.target instanceof HTMLElement)) return
const row = event.target.closest<HTMLElement>('[data-component="menu-v2-item"]')
if (!row) return
// Kobalte's scroll adjustment ignores scroll-padding; native scrolling keeps the ring clear of the edge.
queueMicrotask(() => row.scrollIntoView({ block: "nearest", inline: "nearest" }))
@@ -248,12 +260,16 @@ export function SessionActivityChips(props: {
// Load on first opt-in, then retain both identities for an interruptible crossfade.
function ActivityGlyph(props: { muted?: boolean }) {
const enabled = createMemo((previous) => previous || experiment.chip === "orb", false)
const [orb] = createResource(enabled, async () => {
const { CssAgentOrb } = await import("@/session/agent-glyph/css-agent-orb")
return CssAgentOrb
})
// Guard the first read so loading this experiment never suspends the composer or hides today's glyph.
const loaded = () => orb.state === "ready"
return (
<span data-slot="activity-glyph" data-orb={experiment.chip === "orb" && loaded()} aria-hidden="true">
<span data-slot="activity-glyph-today">
@@ -20,15 +20,28 @@ import {
import { motionTuning } from "@opencode/ui/motion-tuning"
const group = "Composer"
const subsection = "Docks"
const presets = {
snappy: { duration: 0.3, bounce: 0 },
smooth: { duration: 0.4, bounce: 0 },
calm: { duration: 0.55, bounce: 0 },
}
// Opening and closing share one height spring, so a dock leaves the way it came.
const morph = motionTuning.spring("docks.morph", { label: "Height", group, subsection, shipped: "smooth", presets })
const morph = motionTuning.spring("docks.morph", {
label: "Height",
group,
subsection,
description: "Todo, queue and question docks",
target: '[data-component="session-composer-dock"]',
shipped: "smooth",
presets,
})
const fadeIn = motionTuning.spring("docks.fade-in", { label: "Fade in", group, subsection, shipped: "smooth", presets })
// A closing face stays opaque until this much of its box is left, then fades out before the box closes.
const fadeTail = motionTuning.number("docks.fade-tail", {
label: "Fade-out tail",
@@ -39,6 +52,7 @@ const fadeTail = motionTuning.number("docks.fade-tail", {
max: 1,
step: 0.05,
})
const blur = motionTuning.number("docks.blur", {
label: "Blur",
group,
@@ -49,6 +63,7 @@ const blur = motionTuning.number("docks.blur", {
step: 0.5,
unit: "px",
})
export const queueStagger = motionTuning.number("docks.stagger", {
label: "Queue row stagger",
group,
@@ -60,8 +75,11 @@ export const queueStagger = motionTuning.number("docks.stagger", {
unit: "s",
time: "duration",
})
const fade: DurationOptions = { duration: 0.15, bounce: 0 }
const snap = (px: number, dpr = window.devicePixelRatio || 1) => Math.round(px * dpr) / dpr
const surface =
"data-[moving]:overflow-clip data-[dock-border-underlay]:rounded-xl data-[dock-border-underlay]:bg-v2-background-bg-base [[data-color-scheme=dark]_&[data-dock-border-underlay]]:bg-v2-background-bg-layer-01"
@@ -98,12 +116,14 @@ export function ComposerMorph<T extends { key: string }>(props: {
root?.style.removeProperty("height")
list.forEach((item) => {
const content = item.el?.firstElementChild instanceof HTMLElement ? item.el.firstElementChild : undefined
;["position", "inset-inline", "top", "transform", "clip-path", "opacity"].forEach((prop) =>
item.el?.style.removeProperty(prop),
)
;["transform", "filter", "mask-image"].forEach((prop) => content?.style.removeProperty(prop))
})
setFaces(list.filter((item) => !item.leaving()))
return false
}
@@ -120,12 +140,14 @@ export function ComposerMorph<T extends { key: string }>(props: {
const reduced = prefersReducedMotion()
const leaving = list.filter((item) => item.leaving())
const waiting = reduced && leaving.some((item) => item.presence.value > 0.01)
const boxes = new Map(
leaving.map((item) => [
item,
reduced ? (waiting ? item.height : 0) : snap(item.height * Math.max(0, Math.min(1, item.presence.value))),
]),
)
const used = [...boxes.values()].reduce((sum, box) => sum + box, 0)
let offset = 0
list.forEach((item) => {
@@ -151,24 +173,29 @@ export function ComposerMorph<T extends { key: string }>(props: {
const regionHeight = (list: Face<T>[], spring: Spring) => {
if (!prefersReducedMotion()) return snap(spring.value)
const leaving = list.filter((item) => item.leaving() && item.presence.value > 0.01)
return leaving.length ? leaving.reduce((sum, item) => sum + item.height, 0) : spring.target
}
const paint = (dt: number) => {
const list = untrack(faces)
const entering = list.find((item) => !item.leaving())
if (!root || !height || !entering) return false
// A face that just mounted has no height until its ResizeObserver reports one.
if (entering.height > 0 && height.target !== entering.height) height.set(entering.height)
const reduced = prefersReducedMotion()
if (reduced) height.snap(entering.height)
height.step(dt)
if (Math.abs(height.value - height.target) < 0.5 && Math.abs(height.velocity) < 5) height.snap(height.target)
const waiting = reduced && list.some((item) => item.leaving() && item.presence.value > 0.01)
list.forEach((item) => {
if (waiting && !item.leaving()) return
item.presence.step(dt)
if (Math.abs(item.presence.value - item.presence.target) < 0.01 && Math.abs(item.presence.velocity) < 0.05)
item.presence.snap(item.presence.target)
})
@@ -178,6 +205,7 @@ export function ComposerMorph<T extends { key: string }>(props: {
// Written every frame, never read back: the timeline inset follows the region's real height (as in #57559).
root.style.height = `${shown}px`
paintFaces(list, shown)
return true
}
@@ -187,7 +215,9 @@ export function ComposerMorph<T extends { key: string }>(props: {
(next) => {
const list = untrack(faces)
const current = list.find((item) => !item.leaving())
if (current?.key === next.key) return current.set(next)
if (!root || performance.now() - born < 250) return setFaces([face(next, 1)])
const from = height ? height.value : root.getBoundingClientRect().height
@@ -201,6 +231,7 @@ export function ComposerMorph<T extends { key: string }>(props: {
active.forEach((item) => {
if (item === entering) return
item.leave(true)
if (item.el?.contains(document.activeElement) && document.activeElement instanceof HTMLElement)
document.activeElement.blur()
// A closing face's box closes on the region's spring, so the boxes always add up to the region.
@@ -211,10 +242,12 @@ export function ComposerMorph<T extends { key: string }>(props: {
tune(entering.presence, reduced ? fade : fadeIn()).set(1)
height ??= Spring.fromDuration(from, morph())
tune(height, morph())
// Docks always stack above the composer, whichever is coming or going.
const nextFaces = (back ? active : [...active, entering]).toSorted(
(a, b) => Number(a.key === "composer") - Number(b.key === "composer"),
)
const shown = regionHeight(nextFaces, height)
root.toggleAttribute("data-moving", true)
root.toggleAttribute("data-dock-border-underlay", true)
@@ -237,12 +270,16 @@ export function ComposerMorph<T extends { key: string }>(props: {
<div
ref={(el) => {
item.el = el
const observer = new ResizeObserver((entries) => {
const size = entries[0]?.borderBoxSize[0]?.blockSize
if (size !== undefined) item.height = size
})
observer.observe(el)
onCleanup(() => observer.disconnect())
if (height) paintFaces(untrack(faces), snap(height.value))
}}
data-slot="composer-face"
@@ -262,12 +299,14 @@ export function ComposerMorph<T extends { key: string }>(props: {
function face<T extends { key: string }>(value: T, presence: number): Face<T> {
const [current, set] = createSignal(value)
const [leaving, leave] = createSignal(false)
return { key: value.key, value: current, set, leaving, leave, presence: new Spring(presence), height: 0 }
}
function tune(spring: Spring, options: DurationOptions) {
spring.omega = (2 * Math.PI) / options.duration
spring.zeta = 1 - options.bounce
return spring
}
@@ -281,6 +320,7 @@ function styleFace(
const content = input.content ?? el
const hidden = input.hidden ?? 0
el.style.opacity = `${(input.out ? Math.min(1, p / fadeTail()) : p * p).toFixed(4)}`
// Movement, blur and the soft edge go on the content inside the clipping box, never on the box itself, so a blur
// can't bleed past it. Before the content mounts there is nothing to move.
if (prefersReducedMotion() || content === el) return
@@ -303,6 +343,7 @@ export function Grow<T>(props: {
children: JSX.Element | ((item: NonNullable<T>) => JSX.Element)
}) {
const [present, setPresent] = createSignal(!!props.when)
// SAFETY: a truthy `when` is neither null nor undefined.
const item = createMemo((prev?: NonNullable<T>) => (props.when ? (props.when as NonNullable<T>) : prev))
let el: HTMLDivElement | undefined
let entering = !!props.when && !!props.appear
@@ -313,8 +354,10 @@ export function Grow<T>(props: {
([when, shown], prev) => {
if (when && !shown) {
entering = true
return setPresent(true)
}
if (!el || (!entering && (!prev || prev[0] === when))) return
entering = false
grow(el, when, props.fade, props.card, props.close?.(), () => {
@@ -334,7 +377,8 @@ export function Grow<T>(props: {
>
{(() => {
const child = props.children
return typeof child === "function" ? (
return child instanceof Function ? (
<Show when={item()} keyed>
{child}
</Show>
@@ -359,17 +403,21 @@ function grow(
) {
const reduced = prefersReducedMotion()
const current = growing.get(el)
if (current) {
current.open = open
current.done = done
tune(current.spring, reduced ? fade : open ? morph() : (close ?? morph()))
tune(current.presence, reduced ? fade : fadeIn())
return
}
const mb = parseFloat(getComputedStyle(el).marginBottom) || 0
const prevMb =
(!mb && el.previousElementSibling ? parseFloat(getComputedStyle(el.previousElementSibling).marginBottom) : 0) || 0
const state = {
spring: Spring.fromDuration(
open ? 0 : el.getBoundingClientRect().height,
@@ -380,38 +428,51 @@ function grow(
open,
done,
}
growing.set(el, state)
el.toggleAttribute("data-moving", true)
el.toggleAttribute("data-dock-border-underlay", !!card)
if (!reduced) {
el.style.height = `${snap(state.spring.value)}px`
if (open && mb > 0) el.style.marginBottom = "0px"
if (open && prevMb > 0) el.style.marginTop = `${-prevMb}px`
}
if ((withFade || reduced) && open) el.style.opacity = "0"
// The content's height is read once here, then kept current by a ResizeObserver rather than read every frame.
const child = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : undefined
const margin = child ? parseFloat(getComputedStyle(child).marginBottom) || 0 : 0
if (margin < 0) el.style.overflowClipMargin = `${-margin}px`
let full = child ? child.getBoundingClientRect().height + margin : 0
const observer = new ResizeObserver((entries) => {
const size = entries[0]?.borderBoxSize[0]?.blockSize
if (size !== undefined) full = size + margin
})
if (child) observer.observe(child)
frameLoop.add((dt) => {
if (!el.isConnected) {
observer.disconnect()
growing.delete(el)
return false
}
const noMotion = prefersReducedMotion()
const size = state.open ? full : 0
state.spring.set(size).step(dt)
if (Math.abs(state.spring.value - size) < 0.5 && Math.abs(state.spring.velocity) < 5) state.spring.snap(size)
const shown = state.open ? 1 : 0
state.presence.set(shown).step(dt)
if (Math.abs(state.presence.value - shown) < 0.01 && Math.abs(state.presence.velocity) < 0.05)
state.presence.snap(shown)
@@ -419,18 +480,23 @@ function grow(
observer.disconnect()
growing.delete(el)
finish(el, state.open, state.done)
return false
}
const value = snap(Math.max(0, state.spring.value))
const p = Math.max(0, Math.min(1, state.spring.value / Math.max(1, full)))
const presence = Math.max(0, Math.min(1, state.presence.value))
if (!noMotion) {
// Written every frame, never read back: neighbours and the timeline inset follow the real height (as in #57559).
el.style.height = `${value}px`
if (mb > 0) el.style.marginBottom = `${snap(mb * p)}px`
if (prevMb > 0) el.style.marginTop = `${snap(-prevMb * (1 - p))}px`
}
if (withFade || noMotion) {
const child = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : el
styleFace(el, state.open ? presence : p, {
@@ -440,6 +506,7 @@ function grow(
content: child,
})
}
return true
})
}
@@ -461,6 +528,7 @@ function finish(el: HTMLElement, open: boolean, done?: () => void) {
el.style.removeProperty(prop)
child?.style.removeProperty(prop)
})
if (!open) done?.()
}
@@ -468,28 +536,39 @@ function finish(el: HTMLElement, open: boolean, done?: () => void) {
// cancellation, which clears what it painted.
export function cascade(rows: HTMLElement[], step: number) {
const springs = rows.map(() => Spring.fromDuration(0, fadeIn()))
const clear = () =>
rows.forEach((el) => ["opacity", "filter", "transform"].forEach((prop) => el.style.removeProperty(prop)))
let elapsed = 0
const paintRow = (el: HTMLElement, p: number) => {
el.style.opacity = `${(p * p).toFixed(4)}`
el.style.transform = `translateY(${((1 - p) * 4).toFixed(2)}px)`
}
rows.forEach((el) => paintRow(el, 0))
const stop = frameLoop.add((dt) => {
elapsed += dt
springs.forEach((spring, index) => {
if (elapsed < index * step) return
spring.set(1).step(dt)
if (Math.abs(spring.value - 1) < 0.01 && Math.abs(spring.velocity) < 0.05) spring.snap(1)
})
if (springs.every((spring) => spring.value === 1)) {
clear()
return false
}
rows.forEach((el, index) => paintRow(el, Math.max(0, Math.min(1, springs[index]!.value))))
return true
})
return () => {
stop()
clear()
+1 -1
View File
@@ -202,7 +202,7 @@ function SessionScreenContent(props: {
replayPanelToggle({
id: "side",
label: "Toggle the side panel",
label: "Side panel",
toggle: () => session.layout.view().side.toggle(),
})
+19 -3
View File
@@ -4,6 +4,8 @@ import { motionGlobals, motionTuning } from "@opencode/ui/motion-tuning"
const group = "Sidebars"
const subsection = "Open & close"
// Panels in flight, across the app. Native views, which no DOM box clips, stay hidden while any panel moves.
const [flights, setFlights] = createSignal(0)
@@ -12,7 +14,7 @@ export const panelsMoving = () => flights() > 0
// The "James" option, chosen in the preset strip beside Snappy, Smooth and Calm, swaps the section's knobs for its own: the
// layout moves on its layout spring, like the shared spring's, while the panel opens with a short shift and fade (Web
// Animations on the compositor) and closes at once. The shared spring ships; James stays one choice away.
const james = motionTuning.mode("sidebars.james", { label: "James", group })
const james = motionTuning.mode("sidebars.james", { label: "James", group, subsection })
const springs = () => !james()
@@ -20,6 +22,9 @@ const springs = () => !james()
const spring = motionTuning.spring("sidebars", {
label: "Spring",
group,
subsection,
description: "Sidebar and side panel, one spring",
target: "#vertical-tabs-sidebar",
when: springs,
shipped: "smooth",
presets: {
@@ -35,6 +40,8 @@ const scaleByDistance = motionTuning.toggle("sidebars.scaleByDistance", {
label: "Scale by distance",
group,
subsection: "Scale by distance",
description: "Wider panels take a little longer",
target: "#vertical-tabs-sidebar",
experiment: true,
value: true,
})
@@ -96,6 +103,7 @@ export function panelSpring(distance?: number, base = spring(), scale = distance
const style = motionTuning.choice("sidebars.style", {
label: "Motion",
group,
subsection,
when: springs,
value: "spring",
labels: { spring: "Spring", instant: "Instant" },
@@ -106,6 +114,7 @@ const style = motionTuning.choice("sidebars.style", {
const contentsFade = motionTuning.choice("sidebars.contentsFade", {
label: "Contents fade",
group,
subsection,
when: springs,
value: "edge",
labels: { edge: "Edge", off: "Off", spring: "Whole spring" },
@@ -114,6 +123,7 @@ const contentsFade = motionTuning.choice("sidebars.contentsFade", {
const edgeFade = motionTuning.number("sidebars.edgeFade", {
label: "Edge fade",
group,
subsection,
when: () => springs() && contentsFade() === "edge",
value: 15,
min: 5,
@@ -137,6 +147,7 @@ export const panelOpacity = (closed: number) => {
const jamesShift = motionTuning.spring("sidebars.james.shift", {
label: "Movement",
group,
subsection,
when: james,
shipped: "smooth",
presets: {
@@ -149,6 +160,7 @@ const jamesShift = motionTuning.spring("sidebars.james.shift", {
const jamesFade = motionTuning.easing("sidebars.james.fade", {
label: "Fade",
group,
subsection,
when: james,
value: [0.17, 0.85, 0.63, 1.03],
})
@@ -156,6 +168,7 @@ const jamesFade = motionTuning.easing("sidebars.james.fade", {
const jamesFadeDuration = motionTuning.number("sidebars.james.fadeDuration", {
label: "Fade duration",
group,
subsection,
when: james,
value: 0.24,
min: 0.05,
@@ -169,6 +182,7 @@ const jamesFadeDuration = motionTuning.number("sidebars.james.fadeDuration", {
export const jamesPanelShift = motionTuning.number("sidebars.james.panelShift", {
label: "Panel shift",
group,
subsection,
when: james,
value: 46,
min: 0,
@@ -182,6 +196,7 @@ export const jamesPanelShift = motionTuning.number("sidebars.james.panelShift",
const jamesLayout = motionTuning.choice("sidebars.james.layout", {
label: "Layout",
group,
subsection,
when: james,
value: "animate",
labels: { animate: "Animate", off: "Off" },
@@ -191,6 +206,7 @@ const layoutSpring = (direction: string, label: string) =>
motionTuning.spring(`sidebars.james.layout.${direction}`, {
label,
group,
subsection,
when: () => james() && jamesLayout() !== "off",
shipped: "smooth",
presets: {
@@ -385,9 +401,9 @@ function measureBlocks(box: HTMLElement) {
}
}
/** Adds a button that toggles a panel to the Motion workspace's Sidebars section while the panel is mounted. */
/** Adds a button that toggles a panel to the Knobs panel's Open & close page 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 }))
onCleanup(motionTuning.action({ id: `sidebars.${input.id}`, label: input.label, group, subsection, run: input.toggle }))
}
/**
+4 -2
View File
@@ -31,6 +31,8 @@ import { windowDragEnabled } from "@/session/header/drag-area"
// start padding.
const homeClearance = collapsedSidebarToggleEnd - 10
type SidebarPose = { closed: number; peek: number | undefined }
export const sidebarToggleCommand = { id: "sidebar.toggle", keybind: "mod+shift+b" }
export default function Layout(props: ParentProps) {
@@ -62,7 +64,7 @@ export default function Layout(props: ParentProps) {
const toggleSidebar = () => preferences.appearance.setSidebarOpen(!preferences.appearance.sidebarOpen())
// How far closed the docked sidebar is, and how far in its peek has it, undefined while it isn't peeking.
const sidebar: { closed: number; peek: number | undefined } = {
const sidebar: SidebarPose = {
closed: preferences.appearance.sidebarOpen() ? 0 : 1,
peek: undefined,
}
@@ -139,7 +141,7 @@ export default function Layout(props: ParentProps) {
whenDocked: sidebarMotion.whenSettled,
})
replayPanelToggle({ id: "sidebar", label: "Toggle the left sidebar", toggle: toggleSidebar })
replayPanelToggle({ id: "sidebar", label: "Sidebar", toggle: toggleSidebar })
guardClosingFocus({
root: () => state.tabsAside,
+7 -3
View File
@@ -9,6 +9,8 @@ export const sidebarPeekEnabled = motionTuning.toggle("sidebar.peek", {
label: "Peekable sidebar",
group: "Sidebars",
subsection: "Peek",
description: "Float the sidebar from its toggle",
target: 'button[aria-controls="vertical-tabs-sidebar"]',
experiment: true,
value: true,
})
@@ -18,13 +20,13 @@ const peekKnobs = { group: "Sidebars", subsection: "Peek", when: sidebarPeekEnab
// "Matched" slides the peek on the docked sidebar's own spring instead of its quicker one.
const peekMatched = motionTuning.toggle("sidebar.peek.matched", {
...peekKnobs,
label: "Peek matches sidebar spring",
label: "Match sidebar spring",
value: false,
})
const peekSpring = motionTuning.spring("sidebar.peek.spring", {
...peekKnobs,
label: "Peek spring",
label: "Spring",
when: () => sidebarPeekEnabled() && !peekMatched(),
shipped: "smooth",
presets: {
@@ -50,7 +52,7 @@ const dwellKnob = (from: "edge" | "toggle", label: string, value: number) =>
// over the 28px toggle in ~30–60ms on the way to the breadcrumb or the traffic lights. The toggle waits longer because
// the header row is crossed far more often than the bare window edge, and a peek that starts just before a click docks
// smoothly. Both are tunable in Knobs → Sidebars → Peek.
const dwell = { edge: dwellKnob("edge", "Peek edge dwell", 40), toggle: dwellKnob("toggle", "Peek toggle dwell", 80) }
const dwell = { edge: dwellKnob("edge", "Edge dwell", 40), toggle: dwellKnob("toggle", "Toggle dwell", 80) }
/** Whether any session in the vertical sidebar draws its unread dot, published by the sidebar's tab strip. */
export const [sidebarUnread, setSidebarUnread] = createSignal(false)
@@ -239,11 +241,13 @@ export function createSidebarPeek(input: {
// A visible menu, a drag, or typing in one of the sidebar's fields keeps it open wherever the pointer goes.
const held = () => {
const active = document.activeElement
const typing =
active instanceof HTMLElement &&
input.aside()?.contains(active) &&
(active.matches("input, textarea") || active.isContentEditable) &&
active.getClientRects().length > 0
const menu = [...document.querySelectorAll(menus)].some((element) => element.getClientRects().length > 0)
return pressed || typing || menu || !!document.querySelector('[data-dragging="true"]')
@@ -24,6 +24,8 @@ const style = motionTuning.choice("sidebars.preview.style", {
label: "Style",
group: "Sidebars",
subsection: "Hover",
description: "Tab tooltip between rows",
target: "#vertical-tabs-sidebar",
experiment: true,
value: "cursor",
labels: { today: "Today", glide: "Glide", cursor: "Follow cursor" },
@@ -100,7 +102,9 @@ export function TabPreviewPopover(props: {
open,
(now, before) => {
if (!before || now || !triggerEl) return
if (triggerEl === glide.trigger) unwatch()
if (props.orientation === "vertical" && glides()) leave(triggerEl)
},
{ defer: true },
@@ -148,6 +152,7 @@ export function TabPreviewPopover(props: {
tabIndex={-1}
onPointerMove={(event: PointerEvent) => {
glide.pointerY = event.clientY
if (props.orientation === "vertical" && style() === "cursor" && triggerEl === glide.trigger) place()
}}
>
@@ -209,6 +214,7 @@ function show(card: HTMLElement, trigger: HTMLElement) {
if (style() === "cursor") {
watch(trigger)
place()
// The card has no height while it mounts, so clamp again once it's laid out.
if (!glide.rects) requestAnimationFrame(place)
@@ -240,6 +246,7 @@ function cursorOffset() {
const rects = glide.rects ?? measure()
if (!rects) return 0
// The card has no height until it's attached, so a measurement taken as it mounts isn't kept.
if (rects.card > 0) glide.rects = rects
const top = glide.pointerY - rects.row.height / 2
@@ -262,6 +269,7 @@ function watch(trigger: HTMLElement) {
glide.rects = undefined
place()
}
const observer = new ResizeObserver(refresh)
observer.observe(trigger.closest('[data-slot="vertical-tabs"]') ?? trigger)
window.addEventListener("scroll", refresh, { capture: true, passive: true })
+4
View File
@@ -19,6 +19,8 @@ import "./voice.css"
const toggle = motionTuning.toggle("voice.calls", {
label: en["experiment.voiceCalls"],
group: "Voice",
description: "Call button in the composer",
target: ".voice-call-action",
experiment: true,
value: false,
})
@@ -37,8 +39,10 @@ export function VoiceComposer(props: ParentProps<{ session: SessionInfo | undefi
const platform = usePlatform()
const [state, setState] = createStore({ enabled: toggle() })
onCleanup(motionTuning.subscribe(() => setState("enabled", toggle())))
const enabled = () =>
platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && state.enabled
createEffect(() => {
const session = props.session
@@ -1,149 +0,0 @@
import { afterEach, expect, it } from "bun:test"
import { motionTuning } from "@opencode/ui/motion-tuning"
import { mountMotionPanel } from "@opencode/ui/motion-tuning-panel"
const cleanups: (() => void)[] = []
afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup()
document.body.replaceChildren()
})
it("offers Snappy, Smooth and Calm for every spring, with Global pinned and a collapsible card per area", async () => {
const drawer = motionTuning.spring("test.drawer", {
label: "Spring",
group: "Drawer",
presets: {
snappy: { duration: 0.15, bounce: 0 },
smooth: { duration: 0.25, bounce: 0 },
calm: { duration: 0.4, bounce: 0 },
},
})
const image = motionTuning.spring("test.image", {
label: "Spring",
group: "Image open",
subsection: "Lightbox",
presets: {
snappy: { duration: 0.2, bounce: 0 },
smooth: { duration: 0.3, bounce: 0 },
calm: { duration: 0.4, bounce: 0 },
},
})
cleanups.push(motionTuning.action({ id: "test.replay", label: "Replay", group: "Drawer", run: () => {} }))
motionTuning.toggle("test.experiment", { label: "Try it", group: "Drawer", experiment: true, value: false })
const container = document.createElement("div")
document.body.append(container)
cleanups.push(mountMotionPanel(container))
const chip = await waitFor(() =>
[...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Smooth"),
)
const labels = [...container.querySelectorAll("button")].map((button) => button.textContent?.trim())
expect(labels).toContain("Snappy")
expect(labels).toContain("Calm")
expect(labels).not.toContain("default")
// Global pinned, then a folded card per area; an area's replay buttons and sub-headings sit inside its card.
const shown = (selector: string) =>
[...container.querySelectorAll(selector)].flatMap((element) =>
element.closest("[hidden]") === null ? [element.textContent?.trim()] : [],
)
expect(shown(".knobs-group-label")).toEqual(["Global", "Drawer", "Image open"])
expect(
[...container.querySelectorAll(".knobs-group-head[data-foldable]")].map((head) => head.textContent?.trim()),
).toEqual([expect.stringContaining("Drawer"), expect.stringContaining("Image open")])
expect([...container.querySelectorAll(".knobs-subhead-label")].map((label) => label.textContent?.trim())).toEqual([
"Replay",
"Lightbox",
])
expect(container.querySelectorAll("[data-experiment]")).toHaveLength(1)
expect(labels).not.toContain("Jump to…")
expect(drawer()).toEqual({ duration: 0.15, bounce: 0 })
chip.click()
expect(drawer()).toEqual({ duration: 0.25, bounce: 0 })
expect(image()).toEqual({ duration: 0.3, bounce: 0 })
})
it("closes only from its own close button or Esc inside it, never from the app around it", async () => {
let closed = 0
const outside = document.createElement("button")
const container = document.createElement("div")
document.body.append(outside, container)
cleanups.push(mountMotionPanel(container, { onClose: () => closed++ }))
const close = await waitFor(() => container.querySelector<HTMLButtonElement>(".knobs-close") ?? undefined)
outside.focus()
outside.click()
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))
document.dispatchEvent(new KeyboardEvent("keydown", { key: "p", metaKey: true, bubbles: true }))
expect(closed).toBe(0)
const random = await waitFor(
() => container.querySelector<HTMLButtonElement>('button[aria-label="Random"]') ?? undefined,
)
random.focus()
random.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }))
expect(closed).toBe(1)
close.click()
expect(closed).toBe(2)
})
it("jumps with s from inside the panel, and leaves s and the backtick outside it alone", async () => {
const outside = document.createElement("button")
const container = document.createElement("div")
document.body.append(outside, container)
let closed = 0
cleanups.push(mountMotionPanel(container, { onClose: () => closed++ }))
const panel = await waitFor(() => container.querySelector<HTMLElement>(".knobs-panel") ?? undefined)
const press = (target: Element, key: string) =>
target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }))
outside.focus()
press(outside, "s")
press(outside, "`")
expect(panel.classList.contains("knobs-hinting")).toBe(false)
expect(panel.closest("[hidden],[data-leaving]")).toBeNull()
const random = await waitFor(() => panel.querySelector<HTMLButtonElement>('button[aria-label="Random"]') ?? undefined)
random.focus()
press(random, "s")
expect(panel.classList.contains("knobs-hinting")).toBe(true)
const codes = [...panel.querySelectorAll(".knobs-hint")].map((el) => el.textContent ?? "")
expect(codes.length).toBeGreaterThan(0)
// Esc leaves hint mode, not the panel.
press(random, "Escape")
expect(panel.classList.contains("knobs-hinting")).toBe(false)
expect(closed).toBe(0)
const code = codes.find((item) => item.length === 2) ?? codes[0]!
press(random, "s")
;[...code].forEach((key) => press(document.activeElement ?? random, key))
expect(panel.classList.contains("knobs-hinting")).toBe(false)
expect(document.activeElement).not.toBe(random)
expect(panel.contains(document.activeElement)).toBe(true)
})
async function waitFor<T>(find: () => T | undefined) {
for (let i = 0; i < 200; i++) {
const found = find()
if (found) return found
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error("timed out")
}
@@ -450,7 +450,11 @@ it("swaps the spring knobs for James's while it's on", () => {
motionTuning
.list()
.filter(
(knob) => knob.group === "Sidebars" && !knob.subsection && knob.kind !== "mode" && (knob.when?.() ?? true),
(knob) =>
knob.group === "Sidebars" &&
knob.subsection === "Open & close" &&
knob.kind !== "mode" &&
(knob.when?.() ?? true),
)
.map((knob) => knob.label)
-1
View File
@@ -4,7 +4,6 @@
.cursor-grab,
.cursor-grabbing,
.active\:cursor-grabbing:active,
.knobs-panel > header,
[data-slot="image-preview-body"][data-zoomed],
[data-slot="image-preview-body"][data-dragging],
.session-service-config,
@@ -45,6 +45,17 @@ export default {
"motion.label": "KNOBS",
"motion.title": "Knobs",
"motion.tip": "Tune live. Changes persist on this device.",
"motion.close": "Close Knobs",
"motion.off": "Off",
"motion.shipped": "Shipped",
"motion.custom": "Custom",
"motion.presets": "Presets",
"motion.replay": "Replay",
"motion.focus": "Focus",
"motion.reset": "Reset to Shipped",
"motion.enabled": "Enabled",
"motion.duration": "Duration",
"motion.bounce": "Bounce",
"components.label": "COMPONENTS",
"components.tip": "Open the components lab: every story, with live controls and its states",
"speed.label": "SPEED",
@@ -0,0 +1,360 @@
import { For, Show, type Accessor } from "solid-js"
import { sliderFraction, sliderStep, sliderValue, Spring, type DurationOptions } from "@opencode/ui/motion"
import { motionTuning, type Bezier, type MotionKnob } from "@opencode/ui/motion-tuning"
import { changed, reset } from "@opencode/ui/motion-tuning-features"
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { Switch } from "@opencode/ui/switch"
import { Tooltip } from "@opencode/ui/tooltip"
import { useExtension } from "../sdk"
type Of<Kind extends MotionKnob["kind"]> = Extract<MotionKnob, { kind: Kind }>
// One grid for every row on a feature's page: the label, the control, the value right-aligned in its own column, and
// the dot that resets a changed knob.
const row = "grid min-h-11 grid-cols-[104px_minmax(0,1fr)_64px_16px] items-center gap-x-2 px-4"
const sub = "grid min-h-8 grid-cols-[104px_minmax(0,1fr)_64px_16px] items-center gap-x-2 px-4"
/**
* A knob's row on its feature's page. `live` changes whenever any knob does, since the registry isn't reactive. A row
* keeps its knob for its lifetime, so the control under a drag is never replaced.
*/
export function ControlRow(props: { knob: MotionKnob; live: Accessor<number>; label?: string }) {
const knob = props.knob
const label = () => props.label ?? knob.label
if (knob.kind === "number") return <NumberRow knob={knob} live={props.live} label={label()} />
if (knob.kind === "toggle" || knob.kind === "mode") return <ToggleRow knob={knob} live={props.live} label={label()} />
if (knob.kind === "choice") return <ChoiceRow knob={knob} live={props.live} label={label()} />
if (knob.kind === "spring") return <SpringRow knob={knob} live={props.live} label={label()} />
return <EasingRow knob={knob} live={props.live} label={label()} />
}
function NumberRow(props: { knob: Of<"number">; live: Accessor<number>; label: string }) {
const value = read(props.knob, props.live)
return (
<div class={row}>
<Label text={props.label} />
<Track
label={props.label}
value={value()}
shipped={props.knob.initial}
min={props.knob.min}
max={props.knob.max}
step={props.knob.step}
onChange={(next) => motionTuning.set(props.knob, next)}
/>
<Value text={format(value(), props.knob.step, props.knob.unit)} />
<Dot knob={props.knob} live={props.live} />
</div>
)
}
function ToggleRow(props: { knob: Of<"toggle"> | Of<"mode">; live: Accessor<number>; label: string }) {
const value = read(props.knob, props.live)
return (
<div class={row}>
<Label text={props.label} />
<span />
<Switch class="justify-self-end" checked={value()} onChange={(next) => motionTuning.set(props.knob, next)} hideLabel>
{props.label}
</Switch>
<Dot knob={props.knob} live={props.live} />
</div>
)
}
function ChoiceRow(props: { knob: Of<"choice">; live: Accessor<number>; label: string }) {
const value = read(props.knob, props.live)
return (
<div class={row}>
<Label text={props.label} />
<SegmentedControl
class="segmented-control-v2--full-width col-span-2"
aria-label={props.label}
value={value()}
onChange={(next) => {
if (next) motionTuning.set(props.knob, next)
}}
>
<For each={props.knob.options}>
{(option) => (
<SegmentedControlItem class="text-[12px]" style={{ padding: "0 4px" }} value={option}>
<span class="truncate">{props.knob.labels[option] ?? option}</span>
</SegmentedControlItem>
)}
</For>
</SegmentedControl>
<Dot knob={props.knob} live={props.live} />
</div>
)
}
function SpringRow(props: { knob: Of<"spring">; live: Accessor<number>; label: string }) {
const ctx = useExtension()
const value = read(props.knob, props.live)
const set = (patch: Partial<DurationOptions>) => motionTuning.set(props.knob, { ...value(), ...patch })
const span = () => Math.max(value().duration, props.knob.initial.duration) * 1.6
return (
<div class="py-1">
<div class={row}>
<Label text={props.label} />
<Graph ghost={springPath(props.knob.initial, span())} path={springPath(value(), span())} />
<Dot knob={props.knob} live={props.live} />
</div>
<div class={sub}>
<Label text={ctx.t("motion.duration")} sub />
<Track
label={`${props.label} ${ctx.t("motion.duration")}`}
value={value().duration}
shipped={props.knob.initial.duration}
min={0.05}
max={1}
step={0.01}
onChange={(duration) => set({ duration })}
/>
<Value text={format(value().duration, 0.01, "s")} />
</div>
<div class={sub}>
<Label text={ctx.t("motion.bounce")} sub />
<Track
label={`${props.label} ${ctx.t("motion.bounce")}`}
value={value().bounce}
shipped={props.knob.initial.bounce}
min={0}
max={0.6}
step={0.01}
onChange={(bounce) => set({ bounce })}
/>
<Value text={format(value().bounce, 0.01)} />
</div>
</div>
)
}
const handles = [
{ label: "x1", min: 0, max: 1 },
{ label: "y1", min: -0.5, max: 1.5 },
{ label: "x2", min: 0, max: 1 },
{ label: "y2", min: -0.5, max: 1.5 },
]
function EasingRow(props: { knob: Of<"easing">; live: Accessor<number>; label: string }) {
const value = read(props.knob, props.live)
const set = (index: number, next: number) => {
const curve: Bezier = [...value()]
curve[index] = next
motionTuning.set(props.knob, curve)
}
return (
<div class="py-1">
<div class={row}>
<Label text={props.label} />
<Graph ghost={bezierPath(props.knob.initial)} path={bezierPath(value())} />
<Dot knob={props.knob} live={props.live} />
</div>
<For each={handles}>
{(handle, index) => (
<div class={sub}>
<Label text={handle.label} sub />
<Track
label={`${props.label} ${handle.label}`}
value={value()[index()] ?? 0}
shipped={props.knob.initial[index()] ?? 0}
min={handle.min}
max={handle.max}
step={0.01}
onChange={(next) => set(index(), next)}
/>
<Value text={format(value()[index()] ?? 0, 0.01)} />
</div>
)}
</For>
</div>
)
}
function Label(props: { text: string; sub?: boolean }) {
return (
<span
title={props.text}
classList={{
"truncate leading-[18px]": true,
"text-[13px] text-v2-text-text-base": !props.sub,
"ps-3 text-[12px] text-v2-text-text-faint": !!props.sub,
}}
>
{props.text}
</span>
)
}
function Value(props: { text: string }) {
return (
<span dir="ltr" class="justify-self-end whitespace-nowrap font-mono text-[12px] tabular-nums text-v2-text-text-base">
{props.text}
</span>
)
}
// A changed knob's dot resets it to what ships.
function Dot(props: { knob: MotionKnob; live: Accessor<number> }) {
const ctx = useExtension()
const shown = () => {
props.live()
return changed(props.knob)
}
return (
<Show when={shown()} fallback={<span />}>
<Tooltip appearance="compact" value={ctx.t("motion.reset")} placement="top">
<button
type="button"
class="grid size-4 place-items-center rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
aria-label={ctx.t("motion.reset")}
onClick={() => reset(props.knob)}
>
<span class="size-1.5 rounded-full bg-v2-icon-icon-accent" />
</button>
</Tooltip>
</Show>
)
}
// A slider whose track scrubs: press anywhere on it and drag. The amber tick marks what ships.
function Track(props: {
label: string
value: number
shipped: number
min: number
max: number
step: number
onChange: (value: number) => void
}) {
let track!: HTMLDivElement
const spec = () => ({ min: props.min, max: props.max, step: props.step })
const percent = (value: number) => `${sliderFraction(spec(), value) * 100}%`
const at = (x: number) => {
const rect = track.getBoundingClientRect()
return sliderValue(spec(), props.min + ((x - rect.left) / Math.max(1, rect.width)) * (props.max - props.min))
}
const press = (event: PointerEvent) => {
if (event.button !== 0) return
event.preventDefault()
track.setPointerCapture(event.pointerId)
track.focus()
props.onChange(at(event.clientX))
}
const drag = (event: PointerEvent) => {
if (track.hasPointerCapture(event.pointerId)) props.onChange(at(event.clientX))
}
const key = (event: KeyboardEvent) => {
const direction = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[event.key]
if (direction !== 1 && direction !== -1) return
event.preventDefault()
props.onChange(sliderStep(spec(), props.value, direction, event.shiftKey ? 10 : 1))
}
return (
<div
ref={track}
role="slider"
tabIndex={0}
dir="ltr"
aria-label={props.label}
aria-valuemin={props.min}
aria-valuemax={props.max}
aria-valuenow={props.value}
class="group/track relative h-7 min-w-0 cursor-ew-resize touch-none rounded outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
onPointerDown={press}
onPointerMove={drag}
onKeyDown={key}
>
<span class="absolute inset-x-0 top-[13px] h-0.5 rounded-full bg-v2-background-bg-layer-03" />
<span
class="absolute left-0 top-[13px] h-0.5 rounded-full bg-v2-icon-icon-muted group-hover/track:bg-v2-icon-icon-base"
style={{ width: percent(props.value) }}
/>
<span
class="absolute top-[9px] -ml-px h-2.5 w-0.5 rounded-full bg-v2-icon-icon-accent opacity-60"
style={{ left: percent(props.shipped) }}
/>
<span
class="absolute top-[7px] -ml-[7px] size-3.5 rounded-full bg-v2-text-text-base shadow-[var(--v2-elevation-elements)]"
style={{ left: percent(props.value) }}
/>
</div>
)
}
// The curve a spring or easing draws, over the shipped one dashed.
function Graph(props: { ghost: string; path: string }) {
return (
<svg
class="col-span-2 h-12 w-full rounded-md bg-v2-background-bg-layer-01"
viewBox="0 0 240 48"
preserveAspectRatio="none"
aria-hidden="true"
>
<path d={props.ghost} fill="none" class="stroke-v2-icon-icon-faint" stroke-width="1.5" stroke-dasharray="3 3" />
<path d={props.path} fill="none" class="stroke-v2-text-text-base" stroke-width="2" />
</svg>
)
}
function read<K extends MotionKnob>(knob: K, live: Accessor<number>): Accessor<K["value"]> {
return () => {
live()
return knob.value
}
}
function format(value: number, step: number, unit?: string) {
const text = value.toFixed(step < 0.01 ? 3 : step < 0.1 ? 2 : step < 1 ? 1 : 0)
if (!unit) return text
return unit === "%" ? `${text}%` : `${text} ${unit}`
}
// Height runs from 0 at the bottom to 1 near the top, with room above for overshoot.
const y = (value: number) => 42 - value * 32
function springPath(options: DurationOptions, span: number) {
const spring = Spring.fromDuration(0, { duration: Math.max(0.03, options.duration), bounce: Math.min(0.95, options.bounce) })
spring.set(1)
const steps = 96
return Array.from({ length: steps + 1 }, (_, index) => {
const point = `${index === 0 ? "M" : "L"}${((index / steps) * 240).toFixed(1)},${y(spring.value).toFixed(1)}`
spring.step(span / steps)
return point
}).join("")
}
function bezierPath(curve: Bezier) {
const [x1, y1, x2, y2] = curve
const x = (value: number) => 8 + value * 224
return `M${x(0)},${y(0)} C${x(x1)},${y(y1)} ${x(x2)},${y(y2)} ${x(1)},${y(1)}`
}
+425 -26
View File
@@ -1,15 +1,114 @@
import { onCleanup, onMount } from "solid-js"
import { createMemo, createSignal, For, Index, onCleanup, onMount, Show } from "solid-js"
import { Portal } from "solid-js/web"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { springStyle } from "@opencode/ui/motion/solid"
import { motionPresets, motionTuning } from "@opencode/ui/motion-tuning"
import {
activeFeaturePreset,
applyFeaturePreset,
featureChanged,
featureControls,
featurePresets,
motionFeatures,
resetFeature,
type MotionFeature,
} from "@opencode/ui/motion-tuning-features"
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { useExtension } from "../sdk"
import { ControlRow } from "./motion-rows"
// Where the panel was dragged to, as an offset from its bottom-right home; kept per device.
// Where the panel was dragged to, as an offset from its bottom-right home, its width, and the feature it last showed;
// kept per device.
const key = "opencode.motion.panel"
// The Motion workspace, docked above the debug bar's right end until its header is dragged. knobs loads once it opens.
// From this width the list and the open feature sit side by side.
const wideAt = 560
// The push between the list and a feature's page: no bounce, transform and opacity only.
const push = { omega: (2 * Math.PI) / 0.34, zeta: 1 }
/**
* The Knobs panel, docked above the debug bar's right end until its header is dragged: a list of the app's features
* under their areas, each opening to a page with its presets, its controls on one grid, Replay, Focus and Reset. Wide
* enough, the list and the page sit side by side.
*/
export default function Motion(props: { title: string; onClose: () => void }) {
const ctx = useExtension()
let container!: HTMLDivElement
let unmount: (() => void) | undefined
let disposed = false
const offset = load()
const saved = load()
const offset = { x: saved.x, y: saved.y }
const [width, setWidth] = createSignal(saved.width)
const [selected, setSelected] = createSignal(saved.feature)
const [pushed, setPushed] = createSignal(false)
const [stack, setStack] = createSignal<HTMLDivElement>()
// The registry isn't reactive; this ticks on every registration and edit, including the debug bar's.
const [live, setLive] = createSignal(0)
onCleanup(motionTuning.subscribe(() => setLive((value) => value + 1)))
const areas = createMemo(() => {
live()
return motionFeatures()
})
const features = () => areas().flatMap((area) => area.features)
const wide = () => width() >= wideAt
const feature = () => features().find((item) => item.key === selected()) ?? (wide() ? features()[0] : undefined)
const show = outline()
let focusing: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
clearTimeout(focusing)
show(undefined)
})
springStyle(
stack,
() => ({ push: pushed() ? 1 : 0 }),
(element, value) => {
element.style.setProperty("--push", `${value.push}`)
element.toggleAttribute("data-home", value.push === 0 && !pushed())
},
push,
)
// A pushed page makes the list inert before the pointer leaves its row, so the hover outline is cleared here.
const open = (item: MotionFeature) => {
show(undefined)
setSelected(item.key)
setPushed(true)
save({ ...offset, width: width(), feature: item.key })
if (wide()) return
// Shown now rather than on the spring's next frame, so focus can land in the page.
stack()?.removeAttribute("data-home")
container.querySelector<HTMLElement>("[data-motion-page] button")?.focus({ preventScroll: true })
}
const back = () => {
setPushed(false)
container
.querySelector<HTMLElement>(`[data-motion-feature="${CSS.escape(selected() ?? "")}"]`)
?.focus({ preventScroll: true })
}
const focus = (target: string) => {
clearTimeout(focusing)
document.querySelector(target)?.scrollIntoView({ block: "nearest", inline: "nearest" })
show(undefined)
show(target)
focusing = setTimeout(() => show(undefined), 1600)
}
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return
event.preventDefault()
event.stopPropagation()
if (!wide() && pushed()) return back()
props.onClose()
}
const place = () => (container.style.transform = `translate(${offset.x}px, ${offset.y}px)`)
@@ -31,15 +130,14 @@ export default function Motion(props: { title: string; onClose: () => void }) {
const settle = () => {
clamp(bounds(), offset.x, offset.y)
save(offset)
save({ ...offset, width: width(), feature: selected() })
}
const grab = (event: PointerEvent) => {
if (event.button !== 0 || !(event.target instanceof Element)) return
if (event.button !== 0 || !(event.currentTarget instanceof HTMLElement)) return
const header = event.target.closest<HTMLElement>(".knobs-panel > header")
if (!header || event.target.closest("button, input, [role=tab]")) return
if (event.target instanceof Element && event.target.closest("button")) return
const header = event.currentTarget
event.preventDefault()
const limits = bounds()
const start = { x: event.clientX - offset.x, y: event.clientY - offset.y }
@@ -51,7 +149,7 @@ export default function Motion(props: { title: string; onClose: () => void }) {
header.removeEventListener("pointermove", move)
header.removeEventListener("pointerup", drop)
header.removeEventListener("pointercancel", drop)
save(offset)
settle()
}
header.addEventListener("pointermove", move)
@@ -59,6 +157,29 @@ export default function Motion(props: { title: string; onClose: () => void }) {
header.addEventListener("pointercancel", drop)
}
// The left edge resizes; the right edge stays where it is.
const resize = (event: PointerEvent) => {
if (event.button !== 0 || !(event.currentTarget instanceof HTMLElement)) return
const edge = event.currentTarget
event.preventDefault()
const start = { x: event.clientX, width: width() }
edge.setPointerCapture(event.pointerId)
const move = (moved: PointerEvent) =>
setWidth(Math.round(Math.min(Math.max(320, start.width + start.x - moved.clientX), window.innerWidth - 24)))
const drop = () => {
edge.removeEventListener("pointermove", move)
edge.removeEventListener("pointerup", drop)
edge.removeEventListener("pointercancel", drop)
settle()
}
edge.addEventListener("pointermove", move)
edge.addEventListener("pointerup", drop)
edge.addEventListener("pointercancel", drop)
}
onMount(() => {
place()
const observer = new ResizeObserver(settle)
@@ -70,38 +191,316 @@ export default function Motion(props: { title: string; onClose: () => void }) {
})
})
onCleanup(() => {
disposed = true
unmount?.()
})
const list = () => (
<div class="pb-3">
<Index each={areas()}>
{(area) => (
<section>
<h3 class="px-4 pb-1.5 pt-4 text-[11px] font-semibold uppercase leading-4 tracking-[0.06em] text-v2-text-text-faint">
{area().area}
</h3>
<Index each={area().features}>
{(item) => (
<button
type="button"
data-motion-feature={item().key}
aria-current={wide() && feature()?.key === item().key ? "page" : undefined}
class="grid min-h-12 w-full grid-cols-[minmax(0,1fr)_16px_16px] items-center gap-x-2 border-t border-v2-border-border-muted px-4 py-1.5 text-start hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-v2-border-border-focus aria-[current=page]:bg-v2-overlay-simple-overlay-hover"
onPointerEnter={() => {
clearTimeout(focusing)
show(item().target)
}}
onPointerLeave={() => show(undefined)}
onClick={() => open(item())}
>
<span class="min-w-0">
<span class="flex items-baseline gap-2 text-[14px] font-medium leading-5 text-v2-text-text-base">
<span class="truncate">{item().name}</span>
<Show when={item().switch?.value === false}>
<span class="text-[11px] font-medium text-v2-text-text-faint">{ctx.t("motion.off")}</span>
</Show>
<Show when={item().modes.find((mode) => mode.value)}>
{(mode) => <span class="text-[11px] font-medium text-v2-text-text-faint">{mode().label}</span>}
</Show>
</span>
<Show when={item().description}>
{(description) => (
<span class="block truncate text-[12px] leading-[18px] text-v2-text-text-faint">
{description()}
</span>
)}
</Show>
</span>
<span class="grid size-4 place-items-center">
<Show when={featureChanged(item())}>
<span class="size-1.5 rounded-full bg-v2-icon-icon-accent" />
</Show>
</span>
<Show when={!wide()} fallback={<span />}>
<Icon name="outline-chevron-right" size="small" class="text-v2-icon-icon-faint rtl:-scale-x-100" />
</Show>
</button>
)}
</Index>
</section>
)}
</Index>
</div>
)
void import("@opencode/ui/motion-tuning-panel").then(({ mountMotionPanel }) => {
if (!disposed) unmount = mountMotionPanel(container, { title: props.title, onClose: props.onClose })
})
const page = () => (
<Show when={feature()}>
{(item) => (
<FeaturePage
feature={item()}
live={live}
back={wide() ? undefined : back}
onFocus={focus}
/>
)}
</Show>
)
return (
<Portal>
<div
ref={container}
class="pointer-events-auto fixed bottom-10 right-3 z-50 max-h-[calc(100vh-6rem)] w-[320px] overflow-auto [&_.knobs-panel>header]:cursor-grab [&_.knobs-panel>header]:touch-none"
onPointerDown={grab}
/>
class="pointer-events-auto fixed bottom-10 right-3 z-50 flex h-[min(600px,calc(100vh-6rem))] flex-col overflow-hidden rounded-[10px] border border-v2-border-border-muted bg-v2-background-bg-base text-v2-text-text-base shadow-[var(--v2-elevation-floating)]"
style={{ width: `${width()}px` }}
onKeyDown={escape}
>
<div
class="absolute inset-y-0 left-0 z-10 w-1.5 cursor-ew-resize touch-none"
aria-hidden="true"
onPointerDown={resize}
/>
<header
class="flex h-10 shrink-0 cursor-grab touch-none items-center justify-between gap-2 border-b border-v2-border-border-muted ps-4 pe-1.5"
onPointerDown={grab}
>
<h2 class="text-[13px] font-semibold leading-5">{props.title}</h2>
<IconButton
size="small"
variant="ghost-muted"
icon={<Icon name="close" />}
aria-label={ctx.t("motion.close")}
onClick={() => props.onClose()}
/>
</header>
<Show
when={!wide()}
fallback={
<div class="grid min-h-0 flex-1 grid-cols-[240px_minmax(0,1fr)]">
<div class="min-h-0 overflow-y-auto border-e border-v2-border-border-muted">{list()}</div>
<div class="min-h-0 overflow-y-auto">{page()}</div>
</div>
}
>
<div
ref={setStack}
class="group/stack relative min-h-0 flex-1 overflow-hidden [--dir:1] rtl:[--dir:-1]"
data-home=""
>
<div
class="absolute inset-0 overflow-y-auto"
inert={pushed()}
style={{
transform: "translateX(calc(var(--push, 0) * -30% * var(--dir)))",
opacity: "calc(1 - var(--push, 0) * 0.6)",
}}
>
{list()}
</div>
<div
data-motion-page=""
class="absolute inset-0 overflow-y-auto bg-v2-background-bg-base group-data-[home]/stack:invisible"
inert={!pushed()}
style={{ transform: "translateX(calc((1 - var(--push, 0)) * 100% * var(--dir)))" }}
>
{page()}
</div>
</div>
</Show>
</div>
</Portal>
)
}
function FeaturePage(props: {
feature: MotionFeature
live: () => number
back?: () => void
onFocus: (target: string) => void
}) {
const ctx = useExtension()
const state = createMemo(() => {
props.live()
return {
controls: featureControls(props.feature),
presets: featurePresets(props.feature),
active: activeFeaturePreset(props.feature),
changed: featureChanged(props.feature),
on: props.feature.switch?.value !== false,
}
})
const presetLabel = (preset: string) => {
if (preset === "shipped") return ctx.t("motion.shipped")
if (preset === "snappy" || preset === "smooth" || preset === "calm") return motionPresets[preset]
return props.feature.modes.find((item) => item.id === preset)?.label ?? preset
}
return (
<div class="pb-6">
<Show when={props.back}>
{(back) => (
<button
type="button"
class="flex h-9 items-center gap-0.5 ps-2.5 pe-3 text-[13px] font-medium text-v2-text-text-muted hover:text-v2-text-text-base"
onClick={() => back()()}
>
<Icon name="outline-chevron-right" size="small" class="-scale-x-100 rtl:scale-x-100" />
<span>{props.feature.area}</span>
</button>
)}
</Show>
<h3 classList={{ "px-4 text-[17px] font-semibold leading-6": true, "pt-4": !props.back }}>{props.feature.name}</h3>
<Show when={props.feature.description}>
{(description) => <p class="mt-1 px-4 text-[13px] leading-[18px] text-v2-text-text-muted">{description()}</p>}
</Show>
<div class="flex flex-wrap items-center gap-2 px-4 pt-4">
{/* One trigger is Replay; several replay on their own, each named for what it moves. */}
<For each={props.feature.actions}>
{(item) => (
<PageButton
icon="outline-rotate-clockwise"
label={props.feature.actions.length === 1 ? ctx.t("motion.replay") : item.label}
onClick={() => item.run()}
/>
)}
</For>
<Show when={props.feature.target}>
{(target) => (
<PageButton icon="select-element" label={ctx.t("motion.focus")} onClick={() => props.onFocus(target())} />
)}
</Show>
<span class="ms-auto">
<PageButton
icon="outline-reset"
label={ctx.t("motion.reset")}
disabled={!state().changed}
onClick={() => resetFeature(props.feature)}
/>
</span>
</div>
<Show when={state().on && state().presets.length > 0}>
<div class="px-4 pt-4">
<SegmentedControl
class="segmented-control-v2--full-width"
aria-label={ctx.t("motion.presets")}
value={state().active ?? null}
onChange={(preset) => {
if (preset) applyFeaturePreset(props.feature, preset)
}}
>
<For each={state().presets}>
{(preset) => (
<SegmentedControlItem class="text-[12px]" style={{ padding: "0 4px" }} value={preset}>
<span class="truncate">{presetLabel(preset)}</span>
</SegmentedControlItem>
)}
</For>
</SegmentedControl>
<Show when={!state().active}>
<p class="mt-1.5 text-[12px] leading-4 text-v2-text-text-faint">{ctx.t("motion.custom")}</p>
</Show>
</div>
</Show>
<div class="mt-4 divide-y divide-v2-border-border-muted border-t border-v2-border-border-muted">
{/* Keyed: a row keeps the knob it mounted with, so another feature's switch needs a row of its own. */}
<Show when={props.feature.switch} keyed>
{(item) => <ControlRow knob={item} live={props.live} label={ctx.t("motion.enabled")} />}
</Show>
<For each={state().controls}>{(item) => <ControlRow knob={item} live={props.live} />}</For>
</div>
</div>
)
}
function PageButton(props: {
icon: "outline-rotate-clockwise" | "select-element" | "outline-reset"
label: string
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
disabled={props.disabled}
class="flex h-8 items-center gap-1.5 rounded-lg bg-v2-background-bg-layer-01 ps-2.5 pe-3 text-[13px] font-medium text-v2-text-text-base shadow-[0_0_0_0.5px_var(--v2-border-border-base)] hover:bg-v2-background-bg-layer-02 focus-visible:outline focus-visible:outline-2 focus-visible:outline-v2-border-border-focus disabled:pointer-events-none disabled:text-v2-text-text-faint"
onClick={() => props.onClick()}
>
<Icon name={props.icon} size="small" class="text-v2-icon-icon-muted" />
<span>{props.label}</span>
</button>
)
}
// Frames the part of the app a feature changes: a pointer-transparent outline that fades in and out. Its rects are read
// once per change of target, never per frame.
function outline() {
const frames: HTMLElement[] = []
let current: string | undefined
const clear = () =>
frames.splice(0).forEach((frame) => {
frame.style.opacity = "0"
setTimeout(() => frame.remove(), 200)
})
return (target: string | undefined) => {
if (target === current) return
current = target
clear()
if (!target) return
document.querySelectorAll(target).forEach((element) => {
const rect = element.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
const frame = document.body.appendChild(document.createElement("div"))
frame.style.cssText = `position:fixed;z-index:60;pointer-events:none;opacity:0;transition:opacity .15s;border-radius:6px;
left:${rect.left - 3}px;top:${rect.top - 3}px;width:${rect.width + 6}px;height:${rect.height + 6}px;
border:1.5px solid var(--v2-border-border-focus);background:color-mix(in srgb, var(--v2-border-border-focus) 10%, transparent)`
requestAnimationFrame(() => (frame.style.opacity = "1"))
frames.push(frame)
})
}
}
function load() {
try {
const saved = JSON.parse(localStorage.getItem(key) ?? "{}")
return { x: Number(saved.x) || 0, y: Number(saved.y) || 0 }
return {
x: Number(saved.x) || 0,
y: Number(saved.y) || 0,
width: Number(saved.width) || 360,
feature: saved.feature ? String(saved.feature) : undefined,
}
} catch {
return { x: 0, y: 0 }
return { x: 0, y: 0, width: 360, feature: undefined }
}
}
function save(offset: { x: number; y: number }) {
function save(state: { x: number; y: number; width: number; feature: string | undefined }) {
try {
localStorage.setItem(key, JSON.stringify(offset))
localStorage.setItem(key, JSON.stringify(state))
} catch {}
}
@@ -7,6 +7,8 @@ const enabled = motionTuning.toggle("sources.enabled", {
label: "Enabled",
group: "Experiments",
subsection: "Sources",
description: "Pages a turn searched and read",
target: '[data-component="sources-chip"]',
experiment: true,
value: false,
})
+1 -1
View File
@@ -57,7 +57,7 @@
"./motion": "./src/motion/index.ts",
"./motion/solid": "./src/motion/solid.ts",
"./motion-tuning": "./src/motion-tuning.ts",
"./motion-tuning-panel": "./src/motion-tuning-panel.ts",
"./motion-tuning-features": "./src/motion-tuning-features.ts",
"./motion-tuning-fields": "./src/motion-tuning-fields.ts",
"./progress-circle": "./src/feedback/progress-circle/progress-circle.tsx",
"./project-avatar": "./src/data-display/project-avatar/project-avatar.tsx",
@@ -18,8 +18,11 @@ import "./project-avatar.css"
export { generateProjectIcon, GENERATED_PROJECT_ICON_STYLE, type GeneratedProjectIcon } from "./generated"
const avatarChoice = motionTuning.choice("projectAvatar.style", {
label: "Project avatars",
label: "Style",
group: "Projects",
subsection: "Avatars",
description: "Marks for projects without an icon",
target: '[data-component="project-avatar-v2"]',
experiment: true,
value: "letters",
labels: { letters: "Letters", generated: "Generated" },
@@ -0,0 +1,129 @@
import { expect, test } from "bun:test"
import { motionTuning } from "./motion-tuning"
import {
activeFeaturePreset,
applyFeaturePreset,
featureChanged,
featureControls,
featurePresets,
motionFeatures,
resetFeature,
} from "./motion-tuning-features"
const presets = {
snappy: { duration: 0.15, bounce: 0 },
smooth: { duration: 0.25, bounce: 0 },
calm: { duration: 0.4, bounce: 0 },
}
const feature = (area: string, name: string) => {
const found = motionFeatures()
.find((item) => item.area === area)
?.features.find((item) => item.name === name)
if (!found) throw new Error(`no ${area} › ${name}`)
return found
}
test("groups knobs into features under their areas, with the speed left to the debug bar", () => {
motionTuning.spring("features.drawer", { label: "Spring", group: "Drawer", presets })
motionTuning.toggle("features.drawer.dim", { label: "Dim", group: "Drawer", value: true })
motionTuning.toggle("features.lightbox", {
label: "Lightbox",
group: "Drawer",
subsection: "Lightbox",
description: "Images open over the app.",
target: "#lightbox",
experiment: true,
value: false,
})
motionTuning.choice("features.solo", { label: "Solo", group: "Solo area", value: "a", labels: { a: "A", b: "B" } })
const stop = motionTuning.action({ id: "features.replay", label: "Open", group: "Drawer", run: () => {} })
const drawer = motionFeatures().find((item) => item.area === "Drawer")
expect(motionFeatures().some((item) => item.area === "Global")).toBe(false)
expect(drawer?.features.map((item) => item.name)).toEqual(["General", "Lightbox"])
expect(feature("Drawer", "General").actions.map((item) => item.label)).toEqual(["Open"])
expect(feature("Drawer", "Lightbox")).toMatchObject({ description: "Images open over the app.", target: "#lightbox" })
expect(feature("Drawer", "Lightbox").switch?.id).toBe("features.lightbox")
// A lone knob names its area's feature.
expect(feature("Solo area", "Solo").knobs.map((item) => item.id)).toEqual(["features.solo"])
stop()
})
test("matches, applies and resets a feature's presets", () => {
motionTuning.spring("presets.panel", { label: "Spring", group: "Presets", subsection: "Panel", presets })
motionTuning.choice("presets.panel.fade", {
label: "Fade",
group: "Presets",
subsection: "Panel",
value: "edge",
labels: { edge: "Edge", off: "Off" },
})
motionTuning.choice("presets.panel.style", {
label: "Style",
group: "Presets",
subsection: "Panel",
experiment: true,
value: "a",
labels: { a: "A", b: "B" },
})
const james = motionTuning.mode("presets.panel.james", { label: "James", group: "Presets", subsection: "Panel" })
const shift = motionTuning.spring("presets.panel.james.shift", {
label: "Shift",
group: "Presets",
subsection: "Panel",
when: james,
presets,
})
const panel = feature("Presets", "Panel")
const knob = (id: string) => {
const found = panel.knobs.find((item) => item.id === id)
if (!found) throw new Error(`no ${id}`)
return found
}
expect(featurePresets(panel)).toEqual(["shipped", "snappy", "smooth", "calm", "presets.panel.james"])
expect(featureControls(panel).map((item) => item.id)).toEqual(["presets.panel", "presets.panel.fade", "presets.panel.style"])
expect(activeFeaturePreset(panel)).toBe("shipped")
// Trying an experiment's design isn't tuning.
motionTuning.set(knob("presets.panel.style"), "b")
expect(activeFeaturePreset(panel)).toBe("shipped")
expect(featureChanged(panel)).toBe(true)
applyFeaturePreset(panel, "calm")
expect(knob("presets.panel").value).toEqual(presets.calm)
expect(activeFeaturePreset(panel)).toBe("calm")
// A preset sets only springs, so another knob tuned by hand is Custom.
motionTuning.set(knob("presets.panel.fade"), "off")
expect(activeFeaturePreset(panel)).toBeUndefined()
motionTuning.set(knob("presets.panel.fade"), "edge")
expect(activeFeaturePreset(panel)).toBe("calm")
motionTuning.set(knob("presets.panel"), { duration: 0.3, bounce: 0 })
expect(activeFeaturePreset(panel)).toBeUndefined()
applyFeaturePreset(panel, "presets.panel.james")
expect(james()).toBe(true)
expect(activeFeaturePreset(panel)).toBe("presets.panel.james")
expect(featureControls(panel).map((item) => item.id)).toContain("presets.panel.james.shift")
expect(shift()).toEqual(presets.snappy)
applyFeaturePreset(panel, "shipped")
expect(james()).toBe(false)
expect(knob("presets.panel").value).toEqual(presets.snappy)
expect(knob("presets.panel.style").value).toBe("b")
resetFeature(panel)
expect(knob("presets.panel.style").value).toBe("a")
expect(featureChanged(panel)).toBe(false)
})
+161
View File
@@ -0,0 +1,161 @@
import type { DurationOptions } from "./motion"
import { motionTuning, type MotionAction, type MotionKnob } from "./motion-tuning"
type Mode = Extract<MotionKnob, { kind: "mode" }>
type Toggle = Extract<MotionKnob, { kind: "toggle" }>
/** One feature of the app the Knobs panel tunes: an area's subsection, or the area's own knobs. */
export type MotionFeature = {
key: string
area: string
name: string
description?: string
/** A CSS selector for the part of the app the feature changes. */
target?: string
/** Every knob in registration order, modes and the switch included. */
knobs: MotionKnob[]
/** Alternatives offered beside the spring presets, such as James. */
modes: Mode[]
/** The experiment switch that turns the whole feature on or off. */
switch?: Toggle
/** What replays its motion. */
actions: MotionAction[]
}
/** A feature's preset: "shipped", a spring preset, or the id of one of its modes. */
export type MotionFeaturePreset = string
// The areas in the order the panel shows them; any other area follows in the order it registered.
const areas = ["Sidebars", "Timeline", "Composer", "Projects", "Voice"]
// Features named here lead their area in this order, whichever module registered first; the rest follow in the order
// they registered.
const order = ["Open & close", "Scale by distance", "Peek", "Hover", "Docks"]
/** Every registered knob and action as features under their areas, leaving out the speed the debug bar owns. */
export function motionFeatures() {
const knobs = motionTuning.list().filter((item) => item.group !== "Global")
const actions = motionTuning.actions()
const rank = (list: string[], name: string) => (list.includes(name) ? list.indexOf(name) : list.length)
const keys = [...new Set([...knobs, ...actions].map(keyOf))]
const features = keys.map((key): MotionFeature => {
const own = knobs.filter((item) => keyOf(item) === key)
const first = own[0] ?? actions.find((item) => keyOf(item) === key)
const subsection = first?.subsection
return {
key,
area: first?.group ?? key,
// An area's own knobs would repeat the area's name, so a lone knob names its feature, else "General".
name: subsection ?? (own.length === 1 && own[0] ? own[0].label : "General"),
description: own.find((item) => item.description)?.description,
target: own.find((item) => item.target)?.target,
knobs: own,
modes: own.filter((item): item is Mode => item.kind === "mode"),
switch: own.find((item): item is Toggle => item.kind === "toggle" && !!item.experiment && !item.when),
actions: actions.filter((item) => keyOf(item) === key),
}
})
return [...new Set(features.map((feature) => feature.area))]
.toSorted((a, b) => rank(areas, a) - rank(areas, b))
.map((area) => ({
area,
features: features
.filter((feature) => feature.area === area)
.toSorted((a, b) => rank(order, a.name) - rank(order, b.name)),
}))
}
/** The knobs a feature's page shows as rows: what its `when` allows, without modes or the switch. */
export function featureControls(feature: MotionFeature) {
return feature.knobs.filter((item) => item.kind !== "mode" && item !== feature.switch && (item.when?.() ?? true))
}
/** Whether anything in the feature differs from what ships. */
export function featureChanged(feature: MotionFeature) {
return feature.knobs.some(changed)
}
export function changed(item: MotionKnob) {
if (item.kind === "spring") return !sameSpring(item.value, item.initial)
if (item.kind === "easing") return item.value.some((value, index) => value !== item.initial[index])
return item.value !== item.initial
}
/** The presets a feature offers: Shipped and the three spring presets while it shows a spring, then its modes. */
export function featurePresets(feature: MotionFeature): MotionFeaturePreset[] {
const springs = featureControls(feature).some((item) => item.kind === "spring")
if (!springs && feature.modes.length === 0) return []
return ["shipped", "snappy", "smooth", "calm", ...feature.modes.map((item) => item.id)]
}
/**
* The preset the feature's values match, or undefined when they've been tuned by hand. Experiment choices are the
* design under test, not tuning, so they never make a feature Custom.
*/
export function activeFeaturePreset(feature: MotionFeature): MotionFeaturePreset | undefined {
const mode = feature.modes.find((item) => item.value)
if (mode) return mode.id
const tuning = feature.knobs.filter((item) => !item.experiment && item.kind !== "mode")
if (!tuning.some(changed)) return "shipped"
// A spring preset sets only springs, so any other knob tuned by hand makes the feature Custom.
if (tuning.some((item) => item.kind !== "spring" && changed(item))) return
const springs = featureControls(feature).flatMap((item) => (item.kind === "spring" ? [item] : []))
return (["snappy", "smooth", "calm"] as const).find(
(preset) => springs.length > 0 && springs.every((item) => sameSpring(item.value, item.presets[preset])),
)
}
/** Applies a preset: Shipped resets the tuning, a spring preset sets every spring it shows, a mode turns that mode on. */
export function applyFeaturePreset(feature: MotionFeature, preset: MotionFeaturePreset) {
feature.modes.forEach((item) => {
if (item.value !== (item.id === preset)) motionTuning.set(item, item.id === preset)
})
if (preset === "shipped")
return feature.knobs.forEach((item) => {
if (!item.experiment && item.kind !== "mode") reset(item)
})
if (preset !== "snappy" && preset !== "smooth" && preset !== "calm") return
featureControls(feature).forEach((item) => {
if (item.kind === "spring") motionTuning.set(item, { ...item.presets[preset] })
})
}
/** Puts every knob in the feature back to what ships, experiments and modes included. */
export function resetFeature(feature: MotionFeature) {
feature.knobs.forEach(reset)
}
export function reset(item: MotionKnob) {
if (!changed(item)) return
if (item.kind === "spring") return motionTuning.set(item, { ...item.initial })
if (item.kind === "easing") return motionTuning.set(item, [...item.initial])
motionTuning.set(item, item.initial)
}
function keyOf(item: MotionKnob | MotionAction) {
return item.subsection ? `${item.group} · ${item.subsection}` : item.group
}
function sameSpring(a: DurationOptions, b: DurationOptions) {
return a.duration === b.duration && a.bounce === b.bounce
}
-180
View File
@@ -1,180 +0,0 @@
import { motionPresets, motionTuning, type MotionKnob, type MotionPreset } from "./motion-tuning"
const global = "Global"
// The areas in the order the panel shows them; any other area follows in the order it registered.
const areas = ["Sidebars", "Composer", "Projects", "Voice"]
/**
* Mounts the Knobs panel into `container`: the Global card (speed and slow motion) pinned at the top, the shipped values
* and the Snappy, Smooth and Calm presets for every spring at once, then a collapsible card per area of the app with
* its knobs under sub-headings and its replay buttons. Experiments carry a marker after their label. knobs loads on
* first mount, so importing this module costs nothing until then. Works anywhere a surface registered its knobs,
* including Storybook stories. Returns its removal.
*/
export function mountMotionPanel(container: HTMLElement, options: { title?: 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
void start()
return () => {
disposed = true
unmount?.()
}
async function start() {
const [{ action, defineControls }, { applyMotion, motionField }] = await Promise.all([
import("knobs"),
import("./motion-tuning-fields"),
])
// 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 all = shown()
// Modes sit in the preset strip, not as rows. While one is on, the spring presets leave its area's springs alone
// (choosing one switches back to them first); other areas' springs take presets as usual.
const active = all.find((item) => item.kind === "mode" && item.value)
const modes = all.filter((item) => item.kind === "mode")
const list = all.filter((item) => item.kind !== "mode")
const actions = motionTuning.actions()
const mounted = identity()
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]]))
const experiments = new Set(list.flatMap((item) => (item.experiment ? [item.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 rank = (group: string) => (areas.includes(group) ? areas.indexOf(group) : areas.length)
const groups = [...new Set([...list, ...actions].map((item) => item.group))]
.filter((group) => group !== global)
.toSorted((a, b) => rank(a) - rank(b))
const controls = defineControls(
"opencode-motion",
{
// Global first, then each area's knobs and its replay buttons together, so each shows as one card.
...Object.fromEntries(
list
.filter((item) => item.group === global)
.map((item) => [item.id, motionField(item, { label: item.label, group: global })]),
),
...Object.fromEntries(
groups.flatMap((group) => [
...list
.filter((item) => item.group === group)
.map((item) => [item.id, motionField(item, { label: item.label, group, subsection: item.subsection })]),
...actions
.filter((item) => item.group === group)
.map((item) => [
`action.${item.id}`,
action(item.label, { group, subsection: item.subsection ?? "Replay", run: item.run }),
]),
]),
),
},
{
// knobs enables itself only on localhost; this module is only loaded in developer channels.
enabled: true,
ship: () => import("knobs/panel"),
persist: "variants",
pinned: [global],
// Each area folds to its header; knobs remembers what's open for the rest of the session.
groupFolded: Object.fromEntries(groups.map((group) => [group, true])),
// Springs ship different presets, so the defaults get their own chip.
defaultLabel: "Shipped",
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.
const value = patch[item.id] as MotionKnob["value"] | undefined
if (value !== undefined) applyMotion(item, value)
}),
},
)
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 && active.id in controls.presets) 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",
onClose: options.onClose,
// The debug bar shows and hides this panel, and `s` jumps only with focus inside it, so the app keeps its keys.
toggleKey: false,
hints: "panel",
rowAccessory: (key) => (experiments.has(key) ? experimentMark() : null),
})
const stopWatch = motionTuning.subscribe(() => {
// Edits from elsewhere, such as the debug bar's SLOW, show here too.
if (identity() === mounted) return sync()
mount()
})
unmount = () => {
stopWatch()
stopModes()
stopPanel()
}
}
if (!disposed) mount()
}
}
// Marks a row as a design experiment, in the panel's quietest caption style.
function experimentMark() {
const mark = document.createElement("span")
mark.dataset.experiment = ""
mark.textContent = "Exp"
mark.title = "Experiment"
mark.style.cssText = "font-size: var(--_k-text-cap); letter-spacing: 0.06em; color: var(--_k-fg-3)"
return mark
}
// 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])] }))
}
+30 -6
View File
@@ -1,18 +1,22 @@
import type { DurationOptions } from "./motion"
// Live motion settings for developer mode. Each surface registers knobs under the area of the app it belongs to
// (Sidebars, Composer…) with their shipped defaults, plus actions that replay its motion; the Knobs panel
// (`motion-tuning-panel`) shows a collapsible card per area. A knob marked `experiment` is a design variant to try
// before one ships. Edits persist on this device.
// (Sidebars, Composer…) and the feature they tune, with their shipped defaults, plus actions that replay its motion;
// the Knobs panel lists the features (`motion-tuning-features`) and opens one to tune it. A knob marked `experiment`
// is a design variant to try before one ships. Edits persist on this device.
type Knob<Kind extends string, Value> = {
kind: Kind
id: string
label: string
/** The area of the app it tunes: one card in the panel. */
/** The area of the app it tunes: a heading in the panel's list of features. */
group: string
/** A sub-heading within the area's card, such as one feature among several. */
/** The feature it tunes within the area, such as one experiment among several; the area's own knobs have none. */
subsection?: string
/** A short hint at what the feature changes, shown under its name; the first of a feature's knobs to give one wins. */
description?: string
/** A CSS selector for the part of the app the feature changes, outlined by the panel's Focus. */
target?: string
/** The design experiment this knob switches on or between, marked as one in the panel. */
experiment?: boolean
/** Shown only while this holds, e.g. the controls of the option a choice selects. */
@@ -39,7 +43,15 @@ export type MotionKnob =
export type MotionAction = { id: string; label: string; group: string; subsection?: string; run: () => void }
type Meta = { label: string; group: string; subsection?: string; experiment?: boolean; when?: () => boolean }
type Meta = {
label: string
group: string
subsection?: string
description?: string
target?: string
experiment?: boolean
when?: () => boolean
}
// Bumped when knobs are merged or renamed, so values saved for the old ones are dropped.
const key = "opencode.motion.v2"
@@ -64,6 +76,8 @@ export const motionTuning = {
label: meta.label,
group: meta.group,
subsection: meta.subsection,
description: meta.description,
target: meta.target,
experiment: meta.experiment,
when: meta.when,
initial,
@@ -84,6 +98,8 @@ export const motionTuning = {
label: meta.label,
group: meta.group,
subsection: meta.subsection,
description: meta.description,
target: meta.target,
experiment: meta.experiment,
when: meta.when,
initial: meta.value,
@@ -106,6 +122,8 @@ export const motionTuning = {
label: meta.label,
group: meta.group,
subsection: meta.subsection,
description: meta.description,
target: meta.target,
experiment: meta.experiment,
when: meta.when,
initial,
@@ -121,6 +139,8 @@ export const motionTuning = {
label: meta.label,
group: meta.group,
subsection: meta.subsection,
description: meta.description,
target: meta.target,
experiment: meta.experiment,
when: meta.when,
initial: meta.value,
@@ -140,6 +160,8 @@ export const motionTuning = {
label: meta.label,
group: meta.group,
subsection: meta.subsection,
description: meta.description,
target: meta.target,
experiment: meta.experiment,
when: meta.when,
initial: meta.value,
@@ -165,6 +187,8 @@ export const motionTuning = {
label: meta.label,
group: meta.group,
subsection: meta.subsection,
description: meta.description,
target: meta.target,
experiment: meta.experiment,
when: meta.when,
initial: meta.value,