diff --git a/packages/app/src/components-lab/motion.ts b/packages/app/src/components-lab/motion.ts
index 3fee9397f9c..b6acdabc071 100644
--- a/packages/app/src/components-lab/motion.ts
+++ b/packages/app/src/components-lab/motion.ts
@@ -3,12 +3,12 @@ import { applyMotion, motionField } from "@opencode/ui/motion-tuning-fields"
import { action, type Field, type Values } from "knobs"
/**
- * The Knobs a story names in `parameters.motion` (ids, groups or cards) and their replay buttons. Speed and slow motion
+ * The Knobs a story names in `parameters.motion` (ids, areas or sub-headings) and their replay buttons. Speed and slow motion
* come along when there is motion to time.
*/
export function storyMotion(names: readonly string[]) {
- const named = (item: { id: string; group: string; card?: string }) =>
- [item.id, item.group, item.card].some((key) => key !== undefined && names.includes(key))
+ const named = (item: { id: string; group: string; subsection?: string }) =>
+ [item.id, item.group, item.subsection].some((key) => key !== undefined && names.includes(key))
const knobs = motionTuning.list().filter((item) => item.kind !== "mode" && named(item) && (item.when?.() ?? true))
const actions = motionTuning.actions().filter(named)
@@ -30,13 +30,13 @@ export function sameMotion(a: StoryMotion, b: StoryMotion) {
return left.length === right.length && left.every((item, index) => item === right[index])
}
-/** One card per group (or card): its knobs and its replay buttons, then speed and slow motion. */
+/** One card per sub-heading (or area): its knobs and its replay buttons, then speed and slow motion. */
export function motionFields(motion: StoryMotion): { [key: string]: Field } {
return Object.fromEntries([
...motion.knobs.map((item) => [key(item), motionField(item, { label: item.label, group: group(item) })]),
...motion.actions.map((item) => [
`replay.${item.id}`,
- action(item.label, { group: item.group, subsection: "Replay", run: item.run }),
+ action(item.label, { group: item.subsection ?? item.group, subsection: "Replay", run: item.run }),
]),
...motion.globals.map((item) => [key(item), motionField(item, { label: item.label, group: item.group })]),
])
@@ -49,7 +49,11 @@ export function motionFields(motion: StoryMotion): { [key: string]: Field } {
export function motionScopes(motion: StoryMotion) {
const springs = motion.knobs.flatMap((item) => (item.kind === "spring" ? [item] : []))
const scopes = [...new Set(springs.map(group))]
- const groups = [...motion.knobs.map(group), ...motion.actions.map((item) => item.group), ...motion.globals.map(group)]
+ const groups = [
+ ...motion.knobs.map(group),
+ ...motion.actions.map((item) => item.subsection ?? item.group),
+ ...motion.globals.map(group),
+ ]
return {
inPresets: Object.fromEntries(groups.map((name) => [name, false])),
@@ -92,5 +96,5 @@ function key(item: MotionKnob) {
}
function group(item: MotionKnob) {
- return item.card ?? item.group
+ return item.subsection ?? item.group
}
diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx
index 99de332b2f9..e36589d5ec5 100644
--- a/packages/app/src/composer/editor/editor.tsx
+++ b/packages/app/src/composer/editor/editor.tsx
@@ -19,7 +19,6 @@ import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Loader } from "@opencode/ui/loader"
-import { motionTuning } from "@opencode/ui/motion-tuning"
import { ProviderModelIcon } from "@/providers/models/provider-group"
import { useI18n } from "@opencode/ui/context/i18n"
import { Button } from "@opencode/ui/button"
@@ -1421,57 +1420,6 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
)
}
-const sendIcon = motionTuning.choice("composer.sendIcon", {
- label: "Send icon",
- group: "Experiments",
- card: "Icons",
- order: 3,
- value: "today",
- labels: { today: "Today", ios: "iOS", heavy: "Heavier", filled: "Filled" },
-})
-
-const stopIcon = motionTuning.choice("composer.stopIcon", {
- label: "Stop icon",
- group: "Experiments",
- card: "Icons",
- order: 3,
- value: "today",
- labels: { today: "Today", rounded: "Rounded", ios: "iOS", heavy: "Heavier" },
-})
-
-const roundedStopSize = motionTuning.number("composer.stopIcon.size", {
- label: "Rounded stop size",
- group: "Experiments",
- card: "Icons",
- order: 3,
- value: 10,
- min: 6,
- max: 14,
- step: 1,
- unit: "px",
-})
-
-const [icons, setIcons] = createStore({ send: sendIcon(), stop: stopIcon(), size: roundedStopSize() })
-
-motionTuning.subscribe(() => setIcons({ send: sendIcon(), stop: stopIcon(), size: roundedStopSize() }))
-
-// Drawn on a 16px grid so straight edges land on whole pixels at 1×.
-const sendGlyphs = {
- today: undefined,
- ios: ``,
- heavy: ``,
- filled: ``,
-}
-
-function stopGlyph() {
- if (icons.stop === "today") return undefined
-
- const [size, radius] = { rounded: [icons.size, icons.size / 4], ios: [8, 2], heavy: [10, 1] }[icons.stop]
- const start = 8 - size / 2
-
- return ``
-}
-
function ComposerEditorSubmitButton(props: {
mode: ComposerMode
stopping: boolean
@@ -1497,16 +1445,7 @@ function ComposerEditorSubmitButton(props: {
props.held && !props.stopping ? (
) : (
-
- }
- >
- {(glyph) => (
-
- )}
-
+
)
}
aria-busy={props.held && !props.stopping}
diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx
index 46289313e1a..7164ddeca9d 100644
--- a/packages/app/src/session/composer/activity-chips.tsx
+++ b/packages/app/src/session/composer/activity-chips.tsx
@@ -13,15 +13,15 @@ import "./activity-chips.css"
const chip = motionTuning.choice("activity.chip", {
label: en["experiment.activityChip"],
- group: "Experiments",
- order: 1,
+ group: "Composer",
+ 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: "Experiments",
- order: 1,
+ group: "Composer",
+ experiment: true,
value: "capsule",
labels: { capsule: en["experiment.chipCorners.capsule"], composer: en["experiment.chipCorners.composer"] },
})
diff --git a/packages/app/src/session/composer/composer-docks.stories.tsx b/packages/app/src/session/composer/composer-docks.stories.tsx
index d145ff41fe4..7727a0ffb35 100644
--- a/packages/app/src/session/composer/composer-docks.stories.tsx
+++ b/packages/app/src/session/composer/composer-docks.stories.tsx
@@ -42,7 +42,7 @@ const websearchRequest: FormInfo = {
}
// The real composer region with its docks, queue and subagent bar, driven by the story's args or by its replay
-// buttons (the components lab shows them under the Composer docks Knobs). Answering, dismissing and Esc close docks the way they do in the app.
+// buttons (the components lab shows them under Knobs → Composer → Docks). Answering, dismissing and Esc close docks the way they do in the app.
function ComposerDocksPreview(props: DockArgs) {
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
@@ -76,7 +76,7 @@ function ComposerDocksPreview(props: DockArgs) {
)
const replay = (id: string, label: string, run: () => void) =>
- onCleanup(motionTuning.action({ id: `docks.${id}`, label, group: "Composer docks", run }))
+ onCleanup(motionTuning.action({ id: `docks.${id}`, label, group: "Composer", subsection: "Docks", run }))
replay("question", "Question", () => setState({ question: true, revision: state.revision + 1 }))
replay("permission", "Permission", () => setState("permission", true))
@@ -205,11 +205,11 @@ export default {
parameters: {
layout: "fullscreen",
// The components lab shows these Knobs and their replay buttons in its inspector.
- motion: ["Composer docks"],
+ motion: ["Docks"],
docs: {
description: {
component:
- "Request docks, the queue and the subagent bar around the composer. Toggle the args, or use the Replay buttons under the Composer docks Knobs, to open and close each dock and tune its motion live.",
+ "Request docks, the queue and the subagent bar around the composer. Toggle the args, or use the Replay buttons under Knobs → Composer → Docks, to open and close each dock and tune its motion live.",
},
},
},
diff --git a/packages/app/src/session/composer/spring-height.tsx b/packages/app/src/session/composer/spring-height.tsx
index 2c97bdcb2f8..34065f26a2e 100644
--- a/packages/app/src/session/composer/spring-height.tsx
+++ b/packages/app/src/session/composer/spring-height.tsx
@@ -19,19 +19,21 @@ import {
} from "@opencode/ui/motion"
import { motionTuning } from "@opencode/ui/motion-tuning"
-const group = "Composer docks"
+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, shipped: "smooth", presets })
-const fadeIn = motionTuning.spring("docks.fade-in", { label: "Fade in", group, shipped: "smooth", presets })
+const morph = motionTuning.spring("docks.morph", { label: "Height", group, subsection, 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",
group,
+ subsection,
value: 0.4,
min: 0.05,
max: 1,
@@ -40,6 +42,7 @@ const fadeTail = motionTuning.number("docks.fade-tail", {
const blur = motionTuning.number("docks.blur", {
label: "Blur",
group,
+ subsection,
value: 0,
min: 0,
max: 6,
@@ -49,6 +52,7 @@ const blur = motionTuning.number("docks.blur", {
export const queueStagger = motionTuning.number("docks.stagger", {
label: "Queue row stagger",
group,
+ subsection,
value: 0,
min: 0,
max: 0.12,
diff --git a/packages/app/src/session/header/separator.tsx b/packages/app/src/session/header/separator.tsx
index 7153297e291..cf796f5e8d3 100644
--- a/packages/app/src/session/header/separator.tsx
+++ b/packages/app/src/session/header/separator.tsx
@@ -1,72 +1,14 @@
-import { motionTuning } from "@opencode/ui/motion-tuning"
-import { Match, Switch } from "solid-js"
-import { createStore } from "solid-js/store"
+import { Show } from "solid-js"
-const group = "Experiments"
-const card = "Icons"
-const order = 3
+// The chevron's height and stroke, and the visible space on each side of a separator, in px.
+const size = 11
-const glyph = motionTuning.choice("sessionHeader.separator", {
- label: "Separator",
- group,
- card,
- order,
- value: "chevron",
- labels: { divider: "Divider", slash: "Slash", chevron: "Chevron", bold: "Bold chevron", dot: "Dot" },
-})
+const stroke = 1
-const size = motionTuning.number("sessionHeader.separator.size", {
- label: "Separator size",
- group,
- card,
- order,
- value: 11,
- min: 6,
- max: 24,
- step: 1,
- unit: "px",
-})
+const gap = 10
-const weight = motionTuning.number("sessionHeader.separator.weight", {
- label: "Separator weight",
- group,
- card,
- order,
- value: 1,
- min: 0.5,
- max: 3,
- step: 0.25,
- unit: "px",
-})
-
-const opacity = motionTuning.number("sessionHeader.separator.opacity", {
- label: "Separator opacity",
- group,
- card,
- order,
- value: 1,
- min: 0.2,
- max: 1,
- step: 0.05,
-})
-
-const gap = motionTuning.number("sessionHeader.separator.gap", {
- label: "Separator gap (ink to ink)",
- group,
- card,
- order,
- value: 10,
- min: 4,
- max: 20,
- step: 0.5,
- unit: "px",
-})
-
-const read = () => ({ glyph: glyph(), size: size(), weight: weight(), opacity: opacity(), gap: gap() })
-
-const [tuning, setTuning] = createStore(read())
-
-motionTuning.subscribe(() => setTuning(read()))
+// The chevron's ink fills its box, so the gap is exact; the text beside it keeps ~0.5px of side bearing.
+const width = size * 0.45
/**
* Sits between breadcrumb segments with `gap` of visible space on both sides. `before` and `after` are the space the
@@ -75,61 +17,36 @@ motionTuning.subscribe(() => setTuning(read()))
export function SessionHeaderSeparator(props: {
before?: number
after?: number
- glyph?: typeof tuning.glyph
+ glyph?: "chevron" | "slash"
gap?: number
}) {
- const glyph = () => props.glyph ?? tuning.glyph
-
- // The ink of every glyph fills its box, so the gap is exact; the text beside it keeps ~0.5px of side bearing.
- const width = () => {
- if (glyph() === "divider") return tuning.weight
-
- if (glyph() === "dot") return tuning.size / 4
-
- return tuning.size * 0.45
- }
-
- const stroke = () => tuning.weight * (glyph() === "bold" ? 2 : 1)
-
return (
)
}
diff --git a/packages/app/src/session/session-identity-header.css b/packages/app/src/session/session-identity-header.css
index 8133a88c280..4660502e54f 100644
--- a/packages/app/src/session/session-identity-header.css
+++ b/packages/app/src/session/session-identity-header.css
@@ -107,7 +107,6 @@
margin-inline: -0.25px -0.34px;
}
-.session-header-separator[data-glyph="chevron"]:dir(rtl) > svg,
-.session-header-separator[data-glyph="bold"]:dir(rtl) > svg {
+.session-header-separator[data-glyph="chevron"]:dir(rtl) > svg {
transform: scaleX(-1);
}
diff --git a/packages/app/src/session/timeline/message-menu.ts b/packages/app/src/session/timeline/message-menu.ts
index 3d82e45f9c4..5b99101006a 100644
--- a/packages/app/src/session/timeline/message-menu.ts
+++ b/packages/app/src/session/timeline/message-menu.ts
@@ -3,10 +3,8 @@ import { createSignal } from "solid-js"
const toggle = motionTuning.toggle("session.messageMenu", {
label: "Message menu",
- group: "Experiments",
- card: "Experiments",
- // Ahead of the card's other rows (order 1), so it sits at the top of the EXPERIMENTS card.
- order: 0,
+ group: "Composer",
+ experiment: true,
value: false,
})
diff --git a/packages/app/src/shell/panel-motion.ts b/packages/app/src/shell/panel-motion.ts
index 35c3bb6c3f7..4ab3af09951 100644
--- a/packages/app/src/shell/panel-motion.ts
+++ b/packages/app/src/shell/panel-motion.ts
@@ -33,14 +33,22 @@ const spring = motionTuning.spring("sidebars", {
// panel, so a narrow sidebar doesn't crawl and a wide panel doesn't snap.
const scaleByDistance = motionTuning.toggle("sidebars.scaleByDistance", {
label: "Scale by distance",
- group: "Experiments",
+ group,
+ subsection: "Scale by distance",
+ experiment: true,
value: true,
})
const distanceKnob = (
id: string,
meta: { label: string; value: number; min: number; max: number; step: number; unit: string },
-) => motionTuning.number(`sidebars.scaleByDistance.${id}`, { ...meta, group: "Experiments", when: scaleByDistance })
+) =>
+ motionTuning.number(`sidebars.scaleByDistance.${id}`, {
+ ...meta,
+ group,
+ subsection: "Scale by distance",
+ when: scaleByDistance,
+ })
const minDuration = distanceKnob("min", {
label: "Min duration",
diff --git a/packages/app/src/shell/sidebar-peek.ts b/packages/app/src/shell/sidebar-peek.ts
index 003c67d2864..62ca65e368e 100644
--- a/packages/app/src/shell/sidebar-peek.ts
+++ b/packages/app/src/shell/sidebar-peek.ts
@@ -7,11 +7,13 @@ import "./sidebar-peek.css"
export const sidebarPeekEnabled = motionTuning.toggle("sidebar.peek", {
label: "Peekable sidebar",
- group: "Experiments",
+ group: "Sidebars",
+ subsection: "Peek",
+ experiment: true,
value: true,
})
-const peekKnobs = { group: "Experiments", when: sidebarPeekEnabled }
+const peekKnobs = { group: "Sidebars", subsection: "Peek", when: sidebarPeekEnabled }
// "Matched" slides the peek on the docked sidebar's own spring instead of its quicker one.
const peekMatched = motionTuning.toggle("sidebar.peek.matched", {
@@ -47,7 +49,7 @@ const dwellKnob = (from: "edge" | "toggle", label: string, value: number) =>
// reads as immediate, and above how long a pointer spends crossing: through the ~24px edge zone in a frame or two,
// 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 → Experiments.
+// smoothly. Both are tunable in Knobs → Sidebars → Peek.
const dwell = { edge: dwellKnob("edge", "Peek edge dwell", 40), toggle: dwellKnob("toggle", "Peek toggle dwell", 80) }
/** Whether any session in the vertical sidebar draws its unread dot, published by the sidebar's tab strip. */
diff --git a/packages/app/src/shell/titlebar/tab-popover.tsx b/packages/app/src/shell/titlebar/tab-popover.tsx
index f7bd70cb6df..06409b73c44 100644
--- a/packages/app/src/shell/titlebar/tab-popover.tsx
+++ b/packages/app/src/shell/titlebar/tab-popover.tsx
@@ -22,16 +22,17 @@ let activePreview: (() => void) | undefined
const style = motionTuning.choice("sidebars.preview.style", {
label: "Style",
- group: "Experiments",
- card: "Sidebar hover",
+ group: "Sidebars",
+ subsection: "Hover",
+ experiment: true,
value: "cursor",
labels: { today: "Today", glide: "Glide", cursor: "Follow cursor" },
})
const spring = motionTuning.spring("sidebars.preview", {
label: "Glide",
- group: "Experiments",
- card: "Sidebar hover",
+ group: "Sidebars",
+ subsection: "Hover",
when: () => style() === "glide",
presets: {
snappy: { duration: 0.2, bounce: 0 },
diff --git a/packages/app/src/voice/composer.tsx b/packages/app/src/voice/composer.tsx
index ed14613e1fb..594d397aea1 100644
--- a/packages/app/src/voice/composer.tsx
+++ b/packages/app/src/voice/composer.tsx
@@ -12,8 +12,8 @@ import "./voice.css"
const toggle = motionTuning.toggle("voice.calls", {
label: en["experiment.voiceCalls"],
- group: "Experiments",
- card: "Experiments",
+ group: "Voice",
+ experiment: true,
value: false,
})
diff --git a/packages/app/test-browser/motion-tuning-panel.test.ts b/packages/app/test-browser/motion-tuning-panel.test.ts
index 67c5eb370bd..79467e36864 100644
--- a/packages/app/test-browser/motion-tuning-panel.test.ts
+++ b/packages/app/test-browser/motion-tuning-panel.test.ts
@@ -9,7 +9,7 @@ afterEach(() => {
document.body.replaceChildren()
})
-it("offers Snappy, Smooth and Calm for every spring, with one Global card and one card per section", async () => {
+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",
@@ -23,6 +23,7 @@ it("offers Snappy, Smooth and Calm for every spring, with one Global card and on
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 },
@@ -32,9 +33,11 @@ it("offers Snappy, Smooth and Calm for every spring, with one Global card and on
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, { section: "Drawer" }))
+ cleanups.push(mountMotionPanel(container))
const chip = await waitFor(() =>
[...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Smooth"),
@@ -46,14 +49,21 @@ it("offers Snappy, Smooth and Calm for every spring, with one Global card and on
expect(labels).toContain("Calm")
expect(labels).not.toContain("default")
- // Global once, then the section's spring with its replay buttons under a sub-heading in the same card.
+ // 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"])
- expect(shown(".knobs-subhead-label")).toEqual(["Replay"])
+ 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 })
diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts
index fbc45d23ccd..fccd3e2e811 100644
--- a/packages/app/test-browser/panel-motion.test.ts
+++ b/packages/app/test-browser/panel-motion.test.ts
@@ -449,7 +449,9 @@ it("swaps the spring knobs for James's while it's on", () => {
const shown = () =>
motionTuning
.list()
- .filter((knob) => knob.group === "Sidebars" && knob.kind !== "mode" && (knob.when?.() ?? true))
+ .filter(
+ (knob) => knob.group === "Sidebars" && !knob.subsection && knob.kind !== "mode" && (knob.when?.() ?? true),
+ )
.map((knob) => knob.label)
expect(shown()).toEqual(["Spring", "Motion", "Contents fade", "Edge fade"])
diff --git a/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx b/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx
index 6b18b9132bc..7fcdb3c3880 100644
--- a/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx
+++ b/packages/session-ui/src/v2/components/session-progress-indicator-v2.tsx
@@ -2,7 +2,7 @@ import { type ComponentProps } from "solid-js"
import { Spinner } from "@opencode/ui/spinner"
import "./session-progress-indicator-v2.css"
-// The app's one activity spinner, so the activity chip, subagent rows, task cards and loading tabs follow its choice.
+// The app's one activity spinner, shared by the activity chip, subagent rows, task cards and loading tabs.
export function SessionProgressIndicatorV2(props: ComponentProps<"svg">) {
return (
Array.from(pose, (value) => opacity[Number(value)]))
-
-const crcTable = Array.from({ length: 256 }, (_, value) =>
- Array.from({ length: 8 }).reduce((crc) => ((crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1), value),
-)
-
-await Bun.write(new URL("../src/components/spinner.png", import.meta.url), apng(1))
-
-function apng(scale: number) {
- const frameSize = size * scale
- const header = Buffer.alloc(13)
- header.writeUInt32BE(frameSize, 0)
- header.writeUInt32BE(frameSize, 4)
- header[8] = 8
- header[9] = 6
-
- const animation = Buffer.alloc(8)
- animation.writeUInt32BE(frameCount, 0)
- animation.writeUInt32BE(0, 4)
- const chunks = [chunk("IHDR", header), chunk("acTL", animation)]
- const sequence = { value: 0 }
-
- Array.from({ length: frameCount }, (_, frame) => {
- const control = Buffer.alloc(26)
- control.writeUInt32BE(sequence.value++, 0)
- control.writeUInt32BE(frameSize, 4)
- control.writeUInt32BE(frameSize, 8)
- control.writeUInt16BE(1, 20)
- control.writeUInt16BE(frameRate, 22)
- chunks.push(chunk("fcTL", control))
-
- const data = deflateSync(pixels(scale, frame), { level: 9 })
-
- if (frame === 0) {
- chunks.push(chunk("IDAT", data))
-
- return
- }
-
- const frameData = Buffer.alloc(data.length + 4)
- frameData.writeUInt32BE(sequence.value++, 0)
- data.copy(frameData, 4)
- chunks.push(chunk("fdAT", frameData))
- })
-
- return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), ...chunks, chunk("IEND", Buffer.alloc(0))])
-}
-
-function pixels(scale: number, frame: number) {
- const frameSize = size * scale
- const stride = frameSize * 4 + 1
- const pixels = Buffer.alloc(stride * frameSize)
- const progress = (frame / frameCount) * poses.length
- const current = Math.floor(progress)
- const next = (current + 1) % poses.length
- const mix = easeOut(progress - current)
-
- Array.from({ length: frameSize }, (_, y) => (pixels[y * stride] = 0))
- poses[current].forEach((value, index) => {
- draw(
- pixels,
- stride,
- (1 + (index % 5) * 3) * scale,
- (1 + Math.floor(index / 5) * 3) * scale,
- 3 * scale,
- value + (poses[next][index] - value) * mix,
- )
- })
-
- return pixels
-}
-
-function draw(pixels: Buffer, stride: number, x: number, y: number, size: number, opacity: number) {
- const left = Math.floor(x)
- const top = Math.floor(y)
- const right = Math.ceil(x + size)
- const bottom = Math.ceil(y + size)
-
- Array.from({ length: bottom - top }, (_, row) => top + row).forEach((py) =>
- Array.from({ length: right - left }, (_, column) => left + column).forEach((px) => {
- const coverageX = Math.max(0, Math.min(px + 1, x + size) - Math.max(px, x))
- const coverageY = Math.max(0, Math.min(py + 1, y + size) - Math.max(py, y))
- const offset = py * stride + 1 + px * 4
- pixels[offset] = 255
- pixels[offset + 1] = 255
- pixels[offset + 2] = 255
- pixels[offset + 3] = Math.round(opacity * coverageX * coverageY * 255)
- }),
- )
-}
-
-function easeOut(progress: number) {
- const curve = (value: number, first: number, second: number) => {
- const inverse = 1 - value
-
- return 3 * inverse * inverse * value * first + 3 * inverse * value * value * second + value * value * value
- }
-
- const parameter = Array.from({ length: 16 }).reduce<[number, number]>(
- (range) => {
- const middle = (range[0] + range[1]) / 2
-
- return curve(middle, 0, 0.58) < progress ? [middle, range[1]] : [range[0], middle]
- },
- [0, 1],
- )
-
- return curve((parameter[0] + parameter[1]) / 2, 0, 1)
-}
-
-function chunk(type: string, data: Buffer) {
- const name = Buffer.from(type)
- const result = Buffer.alloc(data.length + 12)
- result.writeUInt32BE(data.length, 0)
- name.copy(result, 4)
- data.copy(result, 8)
- result.writeUInt32BE(crc32(Buffer.concat([name, data])), data.length + 8)
-
- return result
-}
-
-function crc32(data: Buffer) {
- return (
- (data.reduce((crc, value) => crcTable[(crc ^ value) & 0xff] ^ (crc >>> 8), 0xffffffff) ^ 0xffffffff) >>> 0
- )
-}
diff --git a/packages/ui/src/components/spinner-png.test.ts b/packages/ui/src/components/spinner-png.test.ts
deleted file mode 100644
index 6998562d9a4..00000000000
--- a/packages/ui/src/components/spinner-png.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { expect, test } from "bun:test"
-
-test("Today spinner PNG loops 36 frames at 30 fps in a 16x16 canvas", async () => {
- const png = Buffer.from(await Bun.file(new URL("./spinner.png", import.meta.url)).arrayBuffer())
-
- expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
-
- const chunks: { type: string; data: Buffer }[] = []
-
- for (let offset = 8; offset < png.length; ) {
- const length = png.readUInt32BE(offset)
- expect(offset + length + 12).toBeLessThanOrEqual(png.length)
- chunks.push({
- type: png.toString("ascii", offset + 4, offset + 8),
- data: png.subarray(offset + 8, offset + 8 + length),
- })
- offset += length + 12
- }
-
- expect(chunks[0].type).toBe("IHDR")
- expect(chunks[0].data.readUInt32BE(0)).toBe(16)
- expect(chunks[0].data.readUInt32BE(4)).toBe(16)
- expect(chunks.at(-1)?.type).toBe("IEND")
-
- const animation = chunks.filter((chunk) => chunk.type === "acTL")
- expect(animation).toHaveLength(1)
- expect(animation[0].data.readUInt32BE(0)).toBe(36)
- expect(animation[0].data.readUInt32BE(4)).toBe(0)
-
- const frames = chunks.filter((chunk) => chunk.type === "fcTL")
- expect(frames).toHaveLength(36)
- const delays = frames.map((frame) => frame.data.readUInt16BE(20) / (frame.data.readUInt16BE(22) || 100))
- expect(delays.every((delay) => delay === 1 / 30)).toBe(true)
- expect(delays.reduce((total, delay) => total + delay, 0)).toBeCloseTo(1.2, 10)
-})
diff --git a/packages/ui/src/components/spinner-radar.test.ts b/packages/ui/src/components/spinner-radar.test.ts
new file mode 100644
index 00000000000..d563ebe86e3
--- /dev/null
+++ b/packages/ui/src/components/spinner-radar.test.ts
@@ -0,0 +1,63 @@
+import { expect, test } from "bun:test"
+import { radarDots, radarKeyframes } from "./spinner-radar"
+
+test("the radar's dots fit the viewBox and light within one loop", () => {
+ const dots = radarDots()
+
+ expect(dots).toHaveLength(25)
+ dots.forEach((dot) => {
+ expect(dot.x).toBeGreaterThanOrEqual(0)
+ expect(dot.x + dot.size).toBeLessThanOrEqual(16)
+ expect(dot.at).toBeGreaterThanOrEqual(0)
+ expect(dot.at).toBeLessThan(1)
+ expect(dot.still).toBeGreaterThanOrEqual(0.2)
+ expect(dot.still).toBeLessThanOrEqual(1)
+ })
+})
+
+test("the radar's brightest dot is never more than half a dot from its head", () => {
+ const css = radarKeyframes("radar")
+ // Head keyframes are linear, so each dot's opacity is a straight line between stops.
+ expect(css).not.toContain("cubic-bezier")
+ const shapes = [...css.matchAll(/@keyframes [\w-]+\{(.*?)\}\}/g)].map((match) =>
+ [...match[1]!.matchAll(/([\d.]+)%\{opacity:([\d.]+)/g)].map((stop) => [Number(stop[1]), Number(stop[2])] as const),
+ )
+ const opacity = (shape: number, percent: number) => {
+ const stops = shapes[shape]!
+ const next = stops.findIndex(([at]) => at >= percent)
+ if (next <= 0) return stops[0]![1]
+ const [p0, o0] = stops[next - 1]!
+ const [p1, o1] = stops[next]!
+ return o0 + ((o1 - o0) * (percent - p0)) / (p1 - p0)
+ }
+ const dots = radarDots()
+ const centre = dots.find((dot) => dot.steady)!
+ const ring = dots
+ .filter((dot) => !dot.steady)
+ .map((dot) => ({
+ ...dot,
+ ring: Math.round(Math.max(Math.abs(dot.x + dot.size / 2 - 8), Math.abs(dot.y + dot.size / 2 - 8))),
+ }))
+ const wrap = (turns: number) => turns - Math.round(turns)
+
+ Array.from({ length: 360 }, (_, step) => step / 360).forEach((head) => {
+ const lit = ring.map((dot) => opacity(dot.shape, ((((head - dot.at) % 1) + 1) % 1) * 100))
+ const top = Math.max(...lit)
+ const brightest = ring.filter((_, index) => lit[index]! > top - 1e-9)
+
+ expect(top).toBeGreaterThan(centre.still)
+ brightest.forEach((dot) => {
+ const offset = wrap(dot.at - head)
+ // The gap to the neighbour on the head's side, on the dot's own ring.
+ const gap = Math.min(
+ ...ring
+ .filter(
+ (other) =>
+ other.ring === dot.ring && other !== dot && Math.sign(wrap(other.at - dot.at)) === -Math.sign(offset),
+ )
+ .map((other) => Math.abs(wrap(other.at - dot.at))),
+ )
+ expect(Math.abs(offset)).toBeLessThanOrEqual(gap / 2 + 0.003)
+ })
+ })
+})
diff --git a/packages/ui/src/components/spinner-radar.ts b/packages/ui/src/components/spinner-radar.ts
new file mode 100644
index 00000000000..88fd523ac42
--- /dev/null
+++ b/packages/ui/src/components/spinner-radar.ts
@@ -0,0 +1,142 @@
+// Soft Radar, the activity spinner: a 5×5 dot grid whose lit head sweeps clockwise around each square ring, leaving a
+// fading trail, with the centre dot steady. Animation is CSS only; every dot shares one loop, so pinning start times with
+// syncSpinPhase keeps every spinner on screen in phase.
+
+const span = 5
+
+/** Dot side and pitch, in a 16-unit viewBox. */
+const size = 2
+
+const pitch = 3
+
+/** Seconds per loop at 1×. */
+export const radarLoop = 1.8
+
+/** Resting opacity of an unlit dot. */
+const dim = 0.2
+
+// The trail's ease-in-out fade over 48% of the loop, sampled every 4%, from a level below the head's.
+const fade = [1, 0.987, 0.934, 0.812, 0.599, 0.384, 0.238, 0.143, 0.081, 0.041, 0.017, 0.004, 0]
+
+/** The trail a dot leaves once the head has passed: [percent of the loop, level from 0 (dim) to 1 (lit)]. */
+const trail = [...fade.map((level, index) => [index * 4, round(0.55 * level)] as const), [100, 0] as const]
+
+export type RadarDot = {
+ x: number
+ y: number
+ size: number
+ /** Where in the loop the dot lights, 0–1. */
+ at: number
+ /** The centre dot stays lit and never animates. */
+ steady: boolean
+ /** Opacity for reduced motion: the sweep frozen a fifth of the way into its loop. */
+ still: number
+ /** Which of the keyframes the dot runs: radarKeyframes names them `name-0`, `name-1`… */
+ shape: number
+}
+
+type Cell = { gx: number; gy: number }
+
+/** The radar's dots, positioned in a 16-unit viewBox. */
+export function radarDots() {
+ return layout().dots
+}
+
+/** The radar's keyframes, one per shape its dots run, named `name-0`, `name-1`… */
+export function radarKeyframes(name: string) {
+ return layout()
+ .shapes.map((stops, index) => {
+ const frames = stops
+ .map(
+ ([percent, level]) =>
+ `${round(percent)}%{opacity:${round(dim + (1 - dim) * level)};animation-timing-function:linear}`,
+ )
+ .join("")
+
+ return `@keyframes ${name}-${index}{${frames}}`
+ })
+ .join("")
+}
+
+function layout() {
+ const placed = Array.from({ length: span * span }, (_, index) => {
+ const cell = { gx: (index % span) - (span - 1) / 2, gy: Math.floor(index / span) - (span - 1) / 2 }
+
+ return { cell, at: angle(cell), steady: cell.gx === 0 && cell.gy === 0 }
+ })
+
+ // Each dot's shape depends on how far it sits from its neighbours on its square ring, in loop fractions.
+ const ringOf = (cell: Cell) => Math.max(Math.abs(cell.gx), Math.abs(cell.gy))
+
+ const keys = placed.map((dot) => {
+ if (dot.steady) return ""
+ const others = placed.filter((other) => other !== dot && !other.steady && ringOf(other.cell) === ringOf(dot.cell))
+ const behind = Math.min(...others.map((other) => frac(dot.at - other.at)))
+ const ahead = Math.min(...others.map((other) => frac(other.at - dot.at)))
+ return `${round(behind)} ${round(ahead)}`
+ })
+ const unique = [...new Set(keys.filter((key) => key !== ""))]
+ const shapes = unique.map((key) => {
+ const [behind, ahead] = key.split(" ").map(Number)
+ return headStops(behind! * 100, ahead! * 100)
+ })
+
+ const dots = placed.map((dot, index): RadarDot => {
+ const shape = Math.max(0, unique.indexOf(keys[index]!))
+ // The steady centre glows like the fresh trail, so the head stays the brightest dot.
+ const level = dot.steady ? trail[0]![1] : levelAt(shapes[shape]!, frac(0.22 - dot.at) * 100)
+
+ return {
+ x: round(8 + dot.cell.gx * pitch - size / 2),
+ y: round(8 + dot.cell.gy * pitch - size / 2),
+ size,
+ at: round(dot.at),
+ steady: dot.steady,
+ still: round(dim + (1 - dim) * level),
+ shape,
+ }
+ })
+
+ return { dots, shapes }
+}
+
+// One dot's level through the loop, 0% being the moment the head passes it, with `behind` and `ahead` the percent of
+// the loop to its neighbours on its ring. It's lit while it's the dot nearest the head, then the trail takes over.
+function headStops(behind: number, ahead: number): (readonly [number, number])[] {
+ const edge = 1.5
+ const off = ahead / 2 + edge
+ const on = 100 - behind / 2
+
+ return [
+ [0, 1],
+ [ahead / 2, 1],
+ [off, levelAt(trail, off)],
+ ...trail.filter(([percent]) => percent > off && percent < 100 && percent < on - edge),
+ [on - edge, 0],
+ [on, 1],
+ [100, 1],
+ ]
+}
+
+// Clockwise from 12 o'clock, 0–1.
+function angle(cell: Cell) {
+ return frac(Math.atan2(cell.gx, -cell.gy) / (2 * Math.PI))
+}
+
+function levelAt(stops: readonly (readonly [number, number])[], percent: number) {
+ const next = stops.findIndex(([at]) => at >= percent)
+
+ if (next <= 0) return stops[0]?.[1] ?? 0
+ const [p0, l0] = stops[next - 1]!
+ const [p1, l1] = stops[next]!
+
+ return l0 + ((l1 - l0) * (percent - p0)) / (p1 - p0 || 1)
+}
+
+function frac(value: number) {
+ return value - Math.floor(value)
+}
+
+function round(value: number) {
+ return Math.round(value * 1000) / 1000
+}
diff --git a/packages/ui/src/components/spinner-variants.test.ts b/packages/ui/src/components/spinner-variants.test.ts
deleted file mode 100644
index b62fbe3edac..00000000000
--- a/packages/ui/src/components/spinner-variants.test.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { expect, test } from "bun:test"
-import { spinnerDots, spinnerKeyframes, spinnerVariants } from "./spinner-variants"
-
-test("every variation's dots fit the viewBox and light within one loop", () => {
- Object.values(spinnerVariants).forEach((variant) => {
- const dots = spinnerDots(variant)
-
- expect(dots.length).toBe(variant.layout === "orb" ? 37 : variant.span ** 2)
- dots.forEach((dot) => {
- expect(dot.x).toBeGreaterThanOrEqual(0)
- expect(dot.x + dot.size).toBeLessThanOrEqual(16)
- expect(dot.at).toBeGreaterThanOrEqual(0)
- expect(dot.at).toBeLessThan(1)
- expect(dot.still).toBeGreaterThanOrEqual(variant.dim)
- expect(dot.still).toBeLessThanOrEqual(1)
- })
- })
-})
-
-test("a border snake rests its middle dots and visits each edge dot once", () => {
- const dots = spinnerDots(spinnerVariants.snake)
- const edge = dots.filter((dot) => !dot.hold)
-
- expect(dots.filter((dot) => dot.hold === "rest")).toHaveLength(4)
- expect(new Set(edge.map((dot) => dot.at)).size).toBe(12)
-})
-
-test("keyframes run from the dim rest to fully lit", () => {
- expect(spinnerKeyframes("spinner-ripple", spinnerVariants.ripple)).toBe(
- "@keyframes spinner-ripple-0{0%{opacity:0.2;animation-timing-function:cubic-bezier(.45,0,.2,1)}3%{opacity:1;animation-timing-function:cubic-bezier(.45,0,.2,1)}37%{opacity:0.2;animation-timing-function:cubic-bezier(.45,0,.2,1)}100%{opacity:0.2;animation-timing-function:cubic-bezier(.45,0,.2,1)}}",
- )
-})
-
-test.each(["radar", "radarSmooth"] as const)(
- "%s's brightest dot is never more than half a dot from its head",
- (name) => {
- const variant = spinnerVariants[name]
- const css = spinnerKeyframes(name, variant)
- // Head keyframes are linear, so each dot's opacity is a straight line between stops.
- expect(css).not.toContain("cubic-bezier")
- const shapes = [...css.matchAll(/@keyframes [\w-]+\{(.*?)\}\}/g)].map((match) =>
- [...match[1]!.matchAll(/([\d.]+)%\{opacity:([\d.]+)/g)].map(
- (stop) => [Number(stop[1]), Number(stop[2])] as const,
- ),
- )
- const opacity = (shape: number, percent: number) => {
- const stops = shapes[shape]!
- const next = stops.findIndex(([at]) => at >= percent)
- if (next <= 0) return stops[0]![1]
- const [p0, o0] = stops[next - 1]!
- const [p1, o1] = stops[next]!
- return o0 + ((o1 - o0) * (percent - p0)) / (p1 - p0)
- }
- const dots = spinnerDots(variant)
- const centre = dots.find((dot) => dot.hold === "lit")!
- const ring = dots
- .filter((dot) => !dot.hold)
- .map((dot) => ({
- ...dot,
- ring: Math.round(Math.max(Math.abs(dot.x + dot.size / 2 - 8), Math.abs(dot.y + dot.size / 2 - 8))),
- }))
- const wrap = (turns: number) => turns - Math.round(turns)
-
- Array.from({ length: 360 }, (_, step) => step / 360).forEach((head) => {
- const lit = ring.map((dot) => opacity(dot.shape, ((((head - dot.at) % 1) + 1) % 1) * 100))
- const top = Math.max(...lit)
- const brightest = ring.filter((_, index) => lit[index]! > top - 1e-9)
-
- expect(top).toBeGreaterThan(centre.still)
- brightest.forEach((dot) => {
- const offset = wrap(dot.at - head)
- // The gap to the neighbour on the head's side, on the dot's own ring.
- const gap = Math.min(
- ...ring
- .filter(
- (other) =>
- other.ring === dot.ring && other !== dot && Math.sign(wrap(other.at - dot.at)) === -Math.sign(offset),
- )
- .map((other) => Math.abs(wrap(other.at - dot.at))),
- )
- expect(Math.abs(offset)).toBeLessThanOrEqual(gap / 2 + 0.003)
- })
- })
- },
-)
diff --git a/packages/ui/src/components/spinner-variants.ts b/packages/ui/src/components/spinner-variants.ts
deleted file mode 100644
index 9738171665b..00000000000
--- a/packages/ui/src/components/spinner-variants.ts
+++ /dev/null
@@ -1,419 +0,0 @@
-// Dot-matrix variations of the activity spinner, Soft Radar by default and others from Knobs → Experiments → "Activity
-// spinner". Each variation
-// is plain data: a dot layout, a keyframe shape, and a pattern that turns each dot's place (angle, distance, path
-// index…) into its animation delay. Animation is CSS only; every dot shares the variation's loop, so pinning start
-// times with syncSpinPhase keeps every spinner on screen in phase. Paste replacements from the prototype's
-// "Try in app" list.
-
-export type SpinnerPattern =
- | "sweep"
- | "bar"
- | "diagonal"
- | "ripple"
- | "twinkle"
- | "border"
- | "breathe"
- | "spiral"
- | "rain"
- | "pulse"
-
-export type SpinnerVariant = {
- label: string
- /** "grid" is span × span; "orb" is the 37-dot circle (rows of 3, 5, 7, 7, 7, 5, 3). */
- layout: "grid" | "orb"
- span: number
- /** Dot side and gap, in a 16-unit viewBox. */
- dot: number
- gap: number
- /** Seconds per loop at 1×. */
- loop: number
- pattern: SpinnerPattern
- /** How far apart neighbouring dots light, for the patterns that spread outward. */
- spread?: number
- /** The centre dot stays lit (radars and scan lines). */
- steady?: boolean
- /** Resting opacity of an unlit dot. */
- dim: number
- /** The keyframe shape: [percent of the loop, level from 0 (dim) to 1 (lit)]. With a head, the trail it leaves. */
- stops: readonly (readonly [number, number])[]
- /**
- * Sweeps on a grid: the head is the brightest dot and `stops` only fade behind it. "step" lights the dot nearest the
- * head on each square ring; "glide" shares the head between the two dots either side of it, so it moves continuously.
- */
- head?: "step" | "glide"
-}
-
-// Soft Radar's trail: its ease-in-out fade over 48% of the loop, sampled every 4%, from a level below the head's.
-const fade = [1, 0.987, 0.934, 0.812, 0.599, 0.384, 0.238, 0.143, 0.081, 0.041, 0.017, 0.004, 0]
-
-function trail(from: number) {
- return [...fade.map((level, index) => [index * 4, round(from * level)] as const), [100, 0] as const]
-}
-
-export const spinnerVariants = {
- radar: {
- label: "Soft Radar",
- layout: "grid",
- span: 5,
- dot: 2,
- gap: 1,
- loop: 1.8,
- pattern: "sweep",
- steady: true,
- dim: 0.2,
- head: "step",
- stops: trail(0.55),
- },
- orbRadar: {
- label: "Orb Radar",
- layout: "orb",
- span: 7,
- dot: 1.5,
- gap: 0.72,
- loop: 2.2,
- pattern: "sweep",
- steady: true,
- dim: 0.2,
- stops: [
- [0, 0],
- [3, 1],
- [62, 0],
- [100, 0],
- ],
- },
- ripple: {
- label: "Centre Ripple",
- layout: "grid",
- span: 5,
- dot: 2,
- gap: 1,
- loop: 1.5,
- pattern: "ripple",
- spread: 0.28,
- dim: 0.2,
- stops: [
- [0, 0],
- [3, 1],
- [37, 0],
- [100, 0],
- ],
- },
- snake: {
- label: "Border Snake",
- layout: "grid",
- span: 4,
- dot: 2.6,
- gap: 1.3,
- loop: 1.2,
- pattern: "border",
- dim: 0.2,
- stops: [
- [0, 0],
- [3, 1],
- [33, 0],
- [100, 0],
- ],
- },
- spiral: {
- label: "Spiral Fill",
- layout: "grid",
- span: 5,
- dot: 2,
- gap: 1,
- loop: 3,
- pattern: "spiral",
- dim: 0.2,
- stops: [
- [0, 0],
- [3, 1],
- [48, 1],
- [54, 0],
- [100, 0],
- ],
- },
- breathe: {
- label: "Breathing Grid",
- layout: "grid",
- span: 3,
- dot: 3.6,
- gap: 2,
- loop: 2,
- pattern: "breathe",
- spread: 0.05,
- dim: 0.2,
- stops: [
- [0, 0],
- [8, 0],
- [50, 1],
- [92, 0],
- [100, 0],
- ],
- },
- // Soft Radar's sweep without the steps: the head lights the two dots either side of it on each ring in proportion
- // to how close it is, so it glides between them. Last, because Knobs saves a choice by its index.
- radarSmooth: {
- label: "Soft Radar (smooth)",
- layout: "grid",
- span: 5,
- dot: 2,
- gap: 1,
- loop: 1.8,
- pattern: "sweep",
- steady: true,
- dim: 0.2,
- head: "glide",
- stops: trail(0.35),
- },
-} satisfies Record
-
-export type SpinnerDot = {
- x: number
- y: number
- size: number
- /** Where in the loop the dot lights, 0–1. */
- at: number
- /** The dot's loop as a multiple of the variation's (twinkle varies it). */
- scale: number
- /** Always lit, or never animated (the resting middle of a border pattern). */
- hold?: "lit" | "rest"
- /** Opacity for reduced motion: the pattern frozen a fifth of the way into its loop. */
- still: number
- /** Which of the variation's keyframes the dot runs: spinnerKeyframes names them `name-0`, `name-1`… */
- shape: number
-}
-
-type Cell = { c: number; r: number; gx: number; gy: number }
-
-/** The dots of a variation, positioned in a 16-unit viewBox. */
-export function spinnerDots(variant: SpinnerVariant) {
- return layout(variant).dots
-}
-
-/** The variation's keyframes, one per shape its dots run, named `name-0`, `name-1`… */
-export function spinnerKeyframes(name: string, variant: SpinnerVariant) {
- return layout(variant)
- .shapes.map((stops, index) => {
- const frames = stops
- .map(
- ([percent, level]) =>
- `${round(percent)}%{opacity:${round(variant.dim + (1 - variant.dim) * level)};animation-timing-function:${variant.head ? "linear" : "cubic-bezier(.45,0,.2,1)"}}`,
- )
- .join("")
-
- return `@keyframes ${name}-${index}{${frames}}`
- })
- .join("")
-}
-
-function layout(variant: SpinnerVariant) {
- const cells = variant.layout === "orb" ? orb() : grid(variant.span)
- const extent = variant.span * variant.dot + (variant.span - 1) * variant.gap
- const fit = Math.min(1, 15 / extent)
- const size = variant.dot * fit
- const pitch = (variant.dot + variant.gap) * fit
- const order = variant.pattern === "spiral" ? spiralOrder(variant.span) : []
- const ring = variant.pattern === "border" ? borderOrder(variant.span) : []
- const random = seeded(4242)
- const offsets = Array.from({ length: variant.span }, () => random())
-
- const placed = cells.map((cell) => {
- const centre = cell.gx === 0 && cell.gy === 0
-
- const twinkle =
- variant.pattern === "twinkle" ? { at: random(), scale: [1, 1.35, 1.7][Math.floor(random() * 3)]! } : undefined
-
- const at = (() => {
- const spread = variant.spread ?? 1
- const dist = Math.hypot(cell.gx, cell.gy)
-
- switch (variant.pattern) {
- case "sweep":
- return angle(cell)
- case "bar":
- return frac(angle(cell) * 2)
- case "diagonal":
- return (cell.c + cell.r) / (2 * variant.span - 1)
- case "ripple":
- case "breathe":
- case "pulse":
- return frac(dist * spread)
- case "twinkle":
- return twinkle!.at
- case "border":
- return Math.max(0, ring.indexOf(cell.r * variant.span + cell.c)) / ring.length
- case "spiral":
- return order.indexOf(cell.r * variant.span + cell.c) / (2 * order.length)
- case "rain":
- return frac(offsets[cell.c]! + cell.r * spread * 0.11)
- }
- })()
-
- const hold =
- variant.steady && centre
- ? ("lit" as const)
- : variant.pattern === "border" && !ring.includes(cell.r * variant.span + cell.c)
- ? ("rest" as const)
- : undefined
-
- return { cell, at, hold, scale: twinkle?.scale ?? 1 }
- })
-
- // With a head, each dot's shape depends on how far it sits from its neighbours on its square ring, in loop fractions.
- const keys = placed.map((dot) => {
- if (!variant.head || dot.hold) return ""
- const ringOf = (cell: Cell) => Math.max(Math.abs(cell.gx), Math.abs(cell.gy))
- const others = placed.filter((other) => other !== dot && !other.hold && ringOf(other.cell) === ringOf(dot.cell))
- const behind = Math.min(...others.map((other) => frac(dot.at - other.at)))
- const ahead = Math.min(...others.map((other) => frac(other.at - dot.at)))
- return `${round(behind)} ${round(ahead)}`
- })
- const unique = [...new Set(keys.filter((key) => key !== ""))]
- const shapes = variant.head
- ? unique.map((key) => {
- const [behind, ahead] = key.split(" ").map(Number)
- return headStops(variant, behind! * 100, ahead! * 100)
- })
- : [variant.stops]
-
- const dots = placed.map((dot, index): SpinnerDot => {
- const shape = Math.max(0, unique.indexOf(keys[index]!))
- // A steady centre glows like the fresh trail, so the head stays the brightest dot.
- const lit = variant.head ? variant.stops[0]![1] : 1
- const level =
- dot.hold === "lit" ? lit : dot.hold === "rest" ? 0 : levelAt(shapes[shape]!, frac(0.22 - dot.at) * 100)
-
- return {
- x: round(8 + dot.cell.gx * pitch - size / 2),
- y: round(8 + dot.cell.gy * pitch - size / 2),
- size: round(size),
- at: round(dot.at),
- scale: dot.scale,
- hold: dot.hold,
- still: round(variant.dim + (1 - variant.dim) * level),
- shape,
- }
- })
-
- return { dots, shapes }
-}
-
-// One dot's level through the loop, 0% being the moment the head passes it, with `behind` and `ahead` the percent of
-// the loop to its neighbours on its ring. "step" lights it while it's the dot nearest the head; "glide" rises linearly
-// from the neighbour behind and falls linearly toward the one ahead. Then the trail takes over, never above the head.
-function headStops(variant: SpinnerVariant, behind: number, ahead: number): (readonly [number, number])[] {
- const edge = 1.5
- const trailAt = (percent: number) => levelAt(variant.stops, percent)
- const tail = (from: number) =>
- variant.stops
- .filter(([percent]) => percent > from && percent < 100)
- .map(([percent, level]) => [percent, level] as const)
-
- if (variant.head === "step") {
- const off = ahead / 2 + edge
- const on = 100 - behind / 2
- return [
- [0, 1],
- [ahead / 2, 1],
- [off, trailAt(off)],
- ...tail(off).filter(([percent]) => percent < on - edge),
- [on - edge, 0],
- [on, 1],
- [100, 1],
- ]
- }
-
- // Where the falling edge meets the trail.
- const meet = Array.from({ length: 40 }).reduce<[number, number]>(
- ([lo, hi]) => {
- const mid = (lo + hi) / 2
- return 1 - mid / ahead > trailAt(mid) ? [mid, hi] : [lo, mid]
- },
- [0, ahead],
- )[0]
- const on = 100 - behind
- return [[0, 1], [meet, trailAt(meet)], ...tail(meet).filter(([percent]) => percent < on), [on, 0], [100, 1]]
-}
-
-function grid(span: number): Cell[] {
- return Array.from({ length: span * span }, (_, index) => {
- const c = index % span
- const r = Math.floor(index / span)
-
- return { c, r, gx: c - (span - 1) / 2, gy: r - (span - 1) / 2 }
- })
-}
-
-function orb(): Cell[] {
- return [3, 5, 7, 7, 7, 5, 3].flatMap((count, r) =>
- Array.from({ length: count }, (_, j) => {
- const c = (7 - count) / 2 + j
-
- return { c, r, gx: c - 3, gy: r - 3 }
- }),
- )
-}
-
-// Clockwise from the top-left corner, as indices into a span × span grid.
-function borderOrder(span: number) {
- const top = Array.from({ length: span }, (_, c) => c)
- const right = Array.from({ length: span - 2 }, (_, r) => (r + 1) * span + span - 1)
- const bottom = Array.from({ length: span }, (_, c) => (span - 1) * span + span - 1 - c)
- const left = Array.from({ length: span - 2 }, (_, r) => (span - 2 - r) * span)
-
- return [...top, ...right, ...bottom, ...left]
-}
-
-// Inward from the top-left corner, clockwise.
-function spiralOrder(span: number) {
- const order: number[] = []
- const edges = { top: 0, left: 0, bottom: span - 1, right: span - 1 }
-
- while (edges.top <= edges.bottom && edges.left <= edges.right) {
- for (let c = edges.left; c <= edges.right; c++) order.push(edges.top * span + c)
- edges.top++
-
- for (let r = edges.top; r <= edges.bottom; r++) order.push(r * span + edges.right)
- edges.right--
-
- if (edges.top <= edges.bottom) for (let c = edges.right; c >= edges.left; c--) order.push(edges.bottom * span + c)
- edges.bottom--
-
- if (edges.left <= edges.right) for (let r = edges.bottom; r >= edges.top; r--) order.push(r * span + edges.left)
- edges.left++
- }
-
- return order
-}
-
-// Clockwise from 12 o'clock, 0–1.
-function angle(cell: Cell) {
- return frac(Math.atan2(cell.gx, -cell.gy) / (2 * Math.PI))
-}
-
-function levelAt(stops: SpinnerVariant["stops"], percent: number) {
- const next = stops.findIndex(([at]) => at >= percent)
-
- if (next <= 0) return stops[0]?.[1] ?? 0
- const [p0, l0] = stops[next - 1]!
- const [p1, l1] = stops[next]!
-
- return l0 + ((l1 - l0) * (percent - p0)) / (p1 - p0 || 1)
-}
-
-function seeded(seed: number) {
- const state = { value: seed }
-
- return () => {
- state.value = (state.value * 16807) % 2147483647
-
- return (state.value - 1) / 2147483646
- }
-}
-
-function frac(value: number) {
- return value - Math.floor(value)
-}
-
-function round(value: number) {
- return Math.round(value * 1000) / 1000
-}
diff --git a/packages/ui/src/components/spinner.css b/packages/ui/src/components/spinner.css
index ab25a90aaae..4863403423a 100644
--- a/packages/ui/src/components/spinner.css
+++ b/packages/ui/src/components/spinner.css
@@ -5,36 +5,7 @@
aspect-ratio: 1;
}
-[data-slot="spinner-grid"] {
- background-color: currentColor;
- /* Preserve the session progress indicator's exact 5x5 frames. A CSS mask keeps instances in phase. */
- mask-image:
- url("./spinner.png"),
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1 1h2v2h-2zM4 1h2v2h-2zM7 1h2v2h-2zM10 1h2v2h-2zM13 1h2v2h-2zM1 4h2v2h-2zM4 4h2v2h-2zM7 4h2v2h-2zM10 4h2v2h-2zM13 4h2v2h-2zM1 7h2v2h-2zM4 7h2v2h-2zM7 7h2v2h-2zM10 7h2v2h-2zM13 7h2v2h-2zM1 10h2v2h-2zM4 10h2v2h-2zM7 10h2v2h-2zM10 10h2v2h-2zM13 10h2v2h-2zM1 13h2v2h-2zM4 13h2v2h-2zM7 13h2v2h-2zM10 13h2v2h-2zM13 13h2v2h-2z'/%3E%3C/svg%3E");
- mask-size: 100% 100%;
- mask-repeat: no-repeat;
- mask-composite: intersect;
- image-rendering: pixelated;
- image-rendering: crisp-edges;
-}
-
-[data-slot="spinner-grid"] [data-reduced-motion] {
- display: none;
-}
-
-:root[data-motion="reduce"] {
- [data-slot="spinner-grid"] {
- background-color: transparent;
- mask-image: none;
- }
-
- [data-slot="spinner-grid"] [data-reduced-motion] {
- display: block;
- }
-}
-
-/* Dot-matrix variations (Knobs → Experiments → Activity spinner). Each dot rests at its frozen pose, so reduced
- motion only has to stop the animation. */
+/* Each dot rests at its frozen pose, so reduced motion only has to stop the animation. */
[data-slot="spinner-dots"] rect {
opacity: var(--still);
}
diff --git a/packages/ui/src/components/spinner.png b/packages/ui/src/components/spinner.png
deleted file mode 100644
index fc158c80f04..00000000000
Binary files a/packages/ui/src/components/spinner.png and /dev/null differ
diff --git a/packages/ui/src/components/spinner.stories.tsx b/packages/ui/src/components/spinner.stories.tsx
index 5e8a26ab096..ab73912b810 100644
--- a/packages/ui/src/components/spinner.stories.tsx
+++ b/packages/ui/src/components/spinner.stories.tsx
@@ -1,42 +1,35 @@
-import { Spinner, type SpinnerVariantName } from "./spinner"
-import { spinnerVariants } from "./spinner-variants"
+import { Spinner } from "./spinner"
const docs = `### Overview
Dot-matrix indicator for AI activity: a running session, turn, subagent or tool. Busy session tabs, \`ActivityRing\`
and the session progress indicator render it. Other loading states use \`Loader\`.
### Variants and states
-- Soft Radar by default; Soft Radar (smooth) glides its sweep head between dots instead of stepping.
-- Today (the 5×5 PNG radar) and the other dot-matrix variations from Knobs → Experiments → Activity spinner; a
- \`variant\` prop pins one.
+- Soft Radar: a lit head sweeps each ring of a 5×5 grid, leaving a fading trail, around a steady centre.
### Behavior
-- CSS animation only. Every dot of a variation shares one loop, and \`syncSpinPhase\` pins every instance to the
- same start, so spinners on screen stay in phase.
-- Reduced motion freezes each variation on a still pose.
+- CSS animation only. Every dot shares one loop, and \`syncSpinPhase\` pins every instance to the same start, so
+ spinners on screen stay in phase.
+- Reduced motion freezes it on a still pose.
### Accessibility
- Use alongside text or aria-live regions to convey loading state.
`
-type Args = { variant: SpinnerVariantName; speed: number; size: number }
+type Args = { speed: number; size: number }
export default {
title: "UI/Spinner",
id: "components-spinner",
component: Spinner,
tags: ["autodocs"],
- args: { variant: "radar", speed: 1, size: 16 },
+ args: { speed: 1, size: 16 },
argTypes: {
- variant: { control: "select", options: ["today", ...Object.keys(spinnerVariants)] },
speed: { control: { type: "range", min: 0.25, max: 2, step: 0.05 } },
size: { control: { type: "range", min: 12, max: 48, step: 1 } },
},
render: (args: Args) => (
-
+
),
parameters: {
docs: {
@@ -47,18 +40,4 @@ export default {
},
}
-export const Today = { args: { variant: "today" } }
-
-export const SoftRadar = { args: { variant: "radar" } }
-
-export const SoftRadarSmooth = { args: { variant: "radarSmooth" } }
-
-export const OrbRadar = { args: { variant: "orbRadar" } }
-
-export const CentreRipple = { args: { variant: "ripple" } }
-
-export const BorderSnake = { args: { variant: "snake" } }
-
-export const SpiralFill = { args: { variant: "spiral" } }
-
-export const BreathingGrid = { args: { variant: "breathe" } }
+export const SoftRadar = {}
diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx
index c831830b763..ddb0edfae3f 100644
--- a/packages/ui/src/components/spinner.tsx
+++ b/packages/ui/src/components/spinner.tsx
@@ -1,101 +1,54 @@
-import { createSignal, For, Show, splitProps, type ComponentProps } from "solid-js"
+import { For, splitProps, type ComponentProps } from "solid-js"
import { syncSpinPhase } from "../hooks/spin-phase"
-import { motionTuning } from "../motion-tuning"
-import { spinnerDots, spinnerKeyframes, spinnerVariants, type SpinnerVariant } from "./spinner-variants"
+import { radarDots, radarKeyframes, radarLoop } from "./spinner-radar"
-export type SpinnerVariantName = "today" | keyof typeof spinnerVariants
+// Every AI activity spinner (busy session tabs, the activity chip, subagents, tool cards) shows Soft Radar. Non-AI
+// loading uses Loader.
-// Every AI activity spinner (busy session tabs, the activity chip, subagents, tool cards) shows Soft Radar; Knobs →
-// Experiments tries the other dot-matrix variations, and Today's PNG, in its place. Non-AI loading uses Loader.
-const choice = motionTuning.choice("spinner.variant", {
- label: "Activity spinner",
- group: "Experiments",
- order: 1,
- value: "radar",
- // SAFETY: built from spinnerVariants' own keys, plus "today".
- labels: Object.fromEntries([
- ["today", "Today"],
- ...Object.entries(spinnerVariants).map(([name, variant]) => [name, variant.label]),
- ]) as Record,
-})
+const keyframes = "spinner-radar"
-const [chosen, setChosen] = createSignal(choice())
+const dots = radarDots()
-motionTuning.subscribe(() => setChosen(choice()))
+// The keyframes are added to the document once, the first time a spinner renders.
+let added = false
-// Each variation's keyframes are added to the document once, the first time one renders.
-const added = new Set()
+export function Spinner(props: ComponentProps<"svg"> & { "data-component"?: string }) {
+ const [local, rest] = splitProps(props, ["class", "classList", "data-component"])
-export function Spinner(props: ComponentProps<"svg"> & { "data-component"?: string; variant?: SpinnerVariantName }) {
- const [local, rest] = splitProps(props, ["variant", "class", "classList", "data-component"])
- const name = () => local.variant ?? chosen()
- // SAFETY: "today" is the only name that isn't a key of spinnerVariants, and it maps to undefined.
- const variant = (): SpinnerVariant | undefined => spinnerVariants[name() as keyof typeof spinnerVariants]
- const classList = () => ({ ...local.classList, [local.class ?? ""]: !!local.class })
+ if (!added) {
+ added = true
+ const style = document.createElement("style")
+ style.textContent = radarKeyframes(keyframes)
+ document.head.append(style)
+ }
return (
- // Keyed, so switching variations builds a fresh SVG: its dots, keyframes and phase pin all come from the new one.
-
-
-
- }
+
+
+ {(dot) => (
+
+ )}
+
+
)
}
diff --git a/packages/ui/src/data-display/project-avatar/project-avatar.tsx b/packages/ui/src/data-display/project-avatar/project-avatar.tsx
index f200ec2e662..00c44f3f422 100644
--- a/packages/ui/src/data-display/project-avatar/project-avatar.tsx
+++ b/packages/ui/src/data-display/project-avatar/project-avatar.tsx
@@ -12,19 +12,15 @@ import {
} from "solid-js"
import { motionTuning } from "../../motion-tuning"
import { attachGeneratedAvatar } from "./animation"
-import {
- generateProjectIcon,
- GENERATED_PROJECT_ICON_STYLE,
- type GeneratedProjectIcon,
-} from "./generated"
+import { generateProjectIcon, GENERATED_PROJECT_ICON_STYLE, type GeneratedProjectIcon } from "./generated"
import "./project-avatar.css"
export { generateProjectIcon, GENERATED_PROJECT_ICON_STYLE, type GeneratedProjectIcon } from "./generated"
const avatarChoice = motionTuning.choice("projectAvatar.style", {
label: "Project avatars",
- group: "Experiments",
- order: 1,
+ group: "Projects",
+ experiment: true,
value: "letters",
labels: { letters: "Letters", generated: "Generated" },
})
diff --git a/packages/ui/src/motion-tuning-fields.ts b/packages/ui/src/motion-tuning-fields.ts
index 2f3daf43c38..f216e46fff0 100644
--- a/packages/ui/src/motion-tuning-fields.ts
+++ b/packages/ui/src/motion-tuning-fields.ts
@@ -3,7 +3,7 @@ import type { DurationOptions } from "./motion"
import { motionTuning, type Bezier, type MotionKnob } from "./motion-tuning"
/** The knobs field that edits a motion knob, defaulting to its shipped value. */
-export function motionField(item: MotionKnob, meta: { label: string; group?: string; tab?: string }) {
+export function motionField(item: MotionKnob, meta: { label: string; group?: string; subsection?: string }) {
if (item.kind === "spring") return spring(item.initial, meta)
if (item.kind === "toggle" || item.kind === "mode") return toggle(item.initial, meta)
diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts
index a473b129500..c75e1d5350b 100644
--- a/packages/ui/src/motion-tuning-panel.ts
+++ b/packages/ui/src/motion-tuning-panel.ts
@@ -2,22 +2,21 @@ import { motionPresets, motionTuning, type MotionKnob, type MotionPreset } from
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 Motion workspace into `container`: a tab per surface under the shipped values and the Snappy, Smooth and
- * Calm presets for every spring at once. Each tab shows one Global card (speed, slow motion and other settings every surface reads), then one
- * card for its surface: its springs and knobs, with its replay buttons under a Replay sub-heading. knobs loads on first
- * mount, so importing this module costs nothing until then. Works anywhere a surface registered its knobs, including
- * Storybook stories. Returns its removal.
+ * 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; section?: string; onClose?: () => void } = {},
-) {
+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
- let section = options.section
void start()
@@ -32,13 +31,6 @@ export function mountMotionPanel(
import("./motion-tuning-fields"),
])
- const field = (item: MotionKnob) =>
- motionField(item, {
- label: item.label,
- group: item.card ?? item.group,
- tab: item.group === global ? undefined : item.group,
- })
-
// 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)
@@ -48,44 +40,45 @@ export function mountMotionPanel(
const mount = () => {
unmount?.()
const all = shown()
- // Modes sit in the preset strip, not as rows. While one is on, the spring presets leave its section's springs alone
- // (choosing one on that section's tab switches back to them first); other sections' springs take presets as usual.
+ // 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 sections = [...new Set([...list, ...actions].map((item) => item.group))].filter((group) => group !== global)
- // A mode sits in the strip only on its own section's tab; switching to or from that tab rebuilds the strip.
- const modes = all.flatMap((item) =>
- item.kind === "mode" && item.group === (section ?? sections[0]) ? [item] : [],
- )
+ const 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 section's knobs and its replay buttons together, so each shows as one card.
- ...Object.fromEntries(list.filter((item) => item.group === global).map((item) => [item.id, field(item)])),
+ // Global first, then each area's knobs and its replay buttons together, so each shows as one card.
...Object.fromEntries(
- [...list, ...actions]
- .filter((item) => item.group !== global)
- .toSorted(
- (a, b) =>
- sections.indexOf(a.group) - sections.indexOf(b.group) ||
- ("order" in a ? (a.order ?? 0) : 0) - ("order" in b ? (b.order ?? 0) : 0),
- )
- .map((item) =>
- "run" in item
- ? [
- `action.${item.id}`,
- action(item.label, { group: item.group, tab: item.group, subsection: "Replay", run: item.run }),
- ]
- : [item.id, field(item)],
- ),
+ 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 }),
+ ]),
+ ]),
),
},
{
@@ -93,6 +86,9 @@ export function mountMotionPanel(
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])) },
@@ -141,17 +137,7 @@ export function mountMotionPanel(
// 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",
- tabs: sections,
- tab: section,
- onTab: (id) => {
- const offered = (group: string | undefined) =>
- all.some((item) => item.kind === "mode" && item.group === group)
-
- const rebuild = offered(section) !== offered(id)
- section = id
-
- if (rebuild) mount()
- },
+ rowAccessory: (key) => (experiments.has(key) ? experimentMark() : null),
})
const stopWatch = motionTuning.subscribe(() => {
@@ -171,6 +157,17 @@ export function mountMotionPanel(
}
}
+// 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
diff --git a/packages/ui/src/motion-tuning.test.ts b/packages/ui/src/motion-tuning.test.ts
index aef154401cd..d5a2d9c1819 100644
--- a/packages/ui/src/motion-tuning.test.ts
+++ b/packages/ui/src/motion-tuning.test.ts
@@ -50,7 +50,7 @@ describe("motionTuning.choice", () => {
test("starts on its shipped option and switches to another", () => {
const choice = motionTuning.choice("test.choice", {
label: "Choice",
- group: "Experiments",
+ group: "Test",
value: "b",
labels: { a: "A", b: "B", c: "C" },
})
diff --git a/packages/ui/src/motion-tuning.ts b/packages/ui/src/motion-tuning.ts
index 6b51d358f83..e5c2408645c 100644
--- a/packages/ui/src/motion-tuning.ts
+++ b/packages/ui/src/motion-tuning.ts
@@ -1,17 +1,20 @@
import type { DurationOptions } from "./motion"
-// Live motion settings for developer mode. Each surface registers knobs under its own group with their shipped defaults,
-// plus actions that replay its motion; the Motion workspace (`motion-tuning-panel`) shows a section per group. Choices
-// in the "Experiments" group are design variants to try before one ships. Edits persist on this device.
+// 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.
type Knob = {
kind: Kind
id: string
label: string
+ /** The area of the app it tunes: one card in the panel. */
group: string
- /** Its own card within the group's tab, such as one experiment among several; `order` places that card. */
- card?: string
- order?: number
+ /** A sub-heading within the area's card, such as one feature among several. */
+ subsection?: 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. */
when?: () => boolean
initial: Value
@@ -34,9 +37,9 @@ export type MotionKnob =
| (Knob<"number", number> & { min: number; max: number; step: number; unit?: string })
| (Knob<"choice", string> & { options: readonly [string, ...string[]]; labels: Record })
-export type MotionAction = { id: string; label: string; group: string; run: () => void }
+export type MotionAction = { id: string; label: string; group: string; subsection?: string; run: () => void }
-type Meta = { label: string; group: string; card?: string; order?: number; when?: () => boolean }
+type Meta = { label: string; group: string; subsection?: 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"
@@ -60,8 +63,8 @@ export const motionTuning = {
id,
label: meta.label,
group: meta.group,
- card: meta.card,
- order: meta.order,
+ subsection: meta.subsection,
+ experiment: meta.experiment,
when: meta.when,
initial,
value: { duration: number(`${id}.duration`, initial.duration), bounce: number(`${id}.bounce`, initial.bounce) },
@@ -80,8 +83,8 @@ export const motionTuning = {
id,
label: meta.label,
group: meta.group,
- card: meta.card,
- order: meta.order,
+ subsection: meta.subsection,
+ experiment: meta.experiment,
when: meta.when,
initial: meta.value,
// SAFETY: mapping the four indices of a Bezier yields four numbers.
@@ -102,8 +105,8 @@ export const motionTuning = {
id,
label: meta.label,
group: meta.group,
- card: meta.card,
- order: meta.order,
+ subsection: meta.subsection,
+ experiment: meta.experiment,
when: meta.when,
initial,
value: number(id, initial ? 1 : 0) === 1,
@@ -117,8 +120,8 @@ export const motionTuning = {
id,
label: meta.label,
group: meta.group,
- card: meta.card,
- order: meta.order,
+ subsection: meta.subsection,
+ experiment: meta.experiment,
when: meta.when,
initial: meta.value,
value: number(id, meta.value ? 1 : 0) === 1,
@@ -136,8 +139,8 @@ export const motionTuning = {
id,
label: meta.label,
group: meta.group,
- card: meta.card,
- order: meta.order,
+ subsection: meta.subsection,
+ experiment: meta.experiment,
when: meta.when,
initial: meta.value,
value: options[number(id, options.indexOf(meta.value))] ?? meta.value,
@@ -161,8 +164,8 @@ export const motionTuning = {
id,
label: meta.label,
group: meta.group,
- card: meta.card,
- order: meta.order,
+ subsection: meta.subsection,
+ experiment: meta.experiment,
when: meta.when,
initial: meta.value,
value: number(id, meta.value),