mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
feat(app): rebuild Knobs as a stack of features (#57921)
This commit is contained in:
committed by
GitHub
Unverified
parent
8d52b4e452
commit
ce447693b1
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -202,7 +202,7 @@ function SessionScreenContent(props: {
|
||||
|
||||
replayPanelToggle({
|
||||
id: "side",
|
||||
label: "Toggle the side panel",
|
||||
label: "Side panel",
|
||||
toggle: () => session.layout.view().side.toggle(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])] }))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user