mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
chore(app): ship settled experiments and organize Knobs by area (#58578)
This commit is contained in:
committed by
GitHub
Unverified
parent
be7539937f
commit
84b4452eb9
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: `<path d="M8 13.5V3.5M3.75 7.75 8 3.5l4.25 4.25" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
heavy: `<path d="M8 14V4M3.5 8.5 8 4l4.5 4.5" stroke="currentColor" stroke-width="2"/>`,
|
||||
filled: `<path d="M8 2l5.5 6H9v6H7V8H2.5z" fill="currentColor"/>`,
|
||||
}
|
||||
|
||||
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 `<rect x="${start}" y="${start}" width="${size}" height="${size}" rx="${radius}" fill="currentColor"/>`
|
||||
}
|
||||
|
||||
function ComposerEditorSubmitButton(props: {
|
||||
mode: ComposerMode
|
||||
stopping: boolean
|
||||
@@ -1497,16 +1445,7 @@ function ComposerEditorSubmitButton(props: {
|
||||
props.held && !props.stopping ? (
|
||||
<Loader class="size-4" />
|
||||
) : (
|
||||
<Show
|
||||
when={props.stopping ? stopGlyph() : props.mode !== "shell" && sendGlyphs[icons.send]}
|
||||
fallback={
|
||||
<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />
|
||||
}
|
||||
>
|
||||
{(glyph) => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" innerHTML={glyph()} />
|
||||
)}
|
||||
</Show>
|
||||
<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />
|
||||
)
|
||||
}
|
||||
aria-busy={props.held && !props.stopping}
|
||||
|
||||
@@ -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"] },
|
||||
})
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
data-glyph={glyph()}
|
||||
data-glyph={props.glyph ?? "chevron"}
|
||||
class="session-header-separator text-v2-text-text-faint"
|
||||
style={{
|
||||
"--separator-gap": `${props.gap ?? tuning.gap}px`,
|
||||
"--separator-gap": `${props.gap ?? gap}px`,
|
||||
"--separator-before": props.before === undefined ? undefined : `${props.before}px`,
|
||||
"--separator-after": props.after === undefined ? undefined : `${props.after}px`,
|
||||
opacity: tuning.opacity,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Switch>
|
||||
<Match when={glyph() === "divider"}>
|
||||
<span class="block rounded-full bg-current" style={{ width: `${width()}px`, height: `${tuning.size}px` }} />
|
||||
</Match>
|
||||
<Match when={glyph() === "dot"}>
|
||||
<span class="block rounded-full bg-current" style={{ width: `${width()}px`, height: `${width()}px` }} />
|
||||
</Match>
|
||||
<Match when={glyph() === "slash"}>
|
||||
<span class="session-header-slash text-v2-icon-icon-faint" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<svg
|
||||
class="block fill-none stroke-current"
|
||||
width={width()}
|
||||
height={tuning.size}
|
||||
viewBox={`0 0 ${width()} ${tuning.size}`}
|
||||
stroke-width={stroke()}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d={`M${stroke() / 2} ${stroke() / 2}L${width() - stroke() / 2} ${tuning.size / 2}L${stroke() / 2} ${tuning.size - stroke() / 2}`}
|
||||
/>
|
||||
</svg>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={props.glyph !== "slash"} fallback={<span class="session-header-slash text-v2-icon-icon-faint" />}>
|
||||
<svg
|
||||
class="block fill-none stroke-current"
|
||||
width={width}
|
||||
height={size}
|
||||
viewBox={`0 0 ${width} ${size}`}
|
||||
stroke-width={stroke}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d={`M${stroke / 2} ${stroke / 2}L${width - stroke / 2} ${size / 2}L${stroke / 2} ${size - stroke / 2}`}
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 (
|
||||
<Spinner
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"src/theme/themes/*.json",
|
||||
"src/components/app-icons/sprite.svg",
|
||||
"src/components/provider-icons/sprite.svg",
|
||||
"src/components/spinner.png",
|
||||
"src/assets/icons/app/*",
|
||||
"src/assets/icons/file-types/*",
|
||||
"src/assets/fonts/*",
|
||||
@@ -105,7 +104,6 @@
|
||||
"test": "bun test src --only-failures",
|
||||
"dev": "vite",
|
||||
"bench:motion": "bun script/motion-bench.ts",
|
||||
"generate:spinner": "bun script/generate-spinner.ts",
|
||||
"generate:tailwind": "bun run script/tailwind.ts",
|
||||
"generate:v2-oc2": "bun run script/build-oc2-v2-overrides.ts"
|
||||
},
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { deflateSync } from "node:zlib"
|
||||
|
||||
const size = 16
|
||||
|
||||
const frameRate = 30
|
||||
|
||||
const duration = 1.2
|
||||
|
||||
const frameCount = frameRate * duration
|
||||
|
||||
const opacity = [0.2, 0.5, 0.75, 1]
|
||||
|
||||
// Each digit selects one dot opacity for one of the eight source key poses.
|
||||
const poses = [
|
||||
"0000000000003000031000321",
|
||||
"0000000000003000320032100",
|
||||
"0000000000333002100010000",
|
||||
"3000023000123000000000000",
|
||||
"1230001300003000000000000",
|
||||
"0012300230003000000000000",
|
||||
"0000100022003330000000000",
|
||||
"0000000000003330002200001",
|
||||
].map((pose) => Array.from(pose, (value) => opacity[Number(value)]))
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, value) =>
|
||||
Array.from({ length: 8 }).reduce<number>((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<number>((crc, value) => crcTable[(crc ^ value) & 0xff] ^ (crc >>> 8), 0xffffffff) ^ 0xffffffff) >>> 0
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -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<string, SpinnerVariant>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -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) => (
|
||||
<Spinner
|
||||
variant={args.variant}
|
||||
style={{ width: `${args.size}px`, height: `${args.size}px`, "--spinner-time": String(1 / args.speed) }}
|
||||
/>
|
||||
<Spinner style={{ width: `${args.size}px`, height: `${args.size}px`, "--spinner-time": String(1 / args.speed) }} />
|
||||
),
|
||||
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 = {}
|
||||
|
||||
@@ -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<SpinnerVariantName, string>,
|
||||
})
|
||||
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<string>()
|
||||
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.
|
||||
<Show
|
||||
when={variant()}
|
||||
keyed
|
||||
fallback={
|
||||
<svg
|
||||
{...rest}
|
||||
viewBox="0 0 16 16"
|
||||
data-component={local["data-component"] ?? "spinner"}
|
||||
data-slot="spinner-grid"
|
||||
classList={classList()}
|
||||
fill="currentColor"
|
||||
>
|
||||
<rect data-reduced-motion x={7} y={7} width={2} height={2} />
|
||||
</svg>
|
||||
}
|
||||
<svg
|
||||
{...rest}
|
||||
ref={syncSpinPhase}
|
||||
viewBox="0 0 16 16"
|
||||
data-component={local["data-component"] ?? "spinner"}
|
||||
data-slot="spinner-dots"
|
||||
classList={{ ...local.classList, [local.class ?? ""]: !!local.class }}
|
||||
fill="currentColor"
|
||||
>
|
||||
{(current) => {
|
||||
const keyframes = `spinner-${name()}`
|
||||
|
||||
if (!added.has(keyframes)) {
|
||||
added.add(keyframes)
|
||||
const style = document.createElement("style")
|
||||
style.textContent = spinnerKeyframes(keyframes, current)
|
||||
document.head.append(style)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...rest}
|
||||
ref={syncSpinPhase}
|
||||
viewBox="0 0 16 16"
|
||||
data-component={local["data-component"] ?? "spinner"}
|
||||
data-slot="spinner-dots"
|
||||
data-variant={name()}
|
||||
classList={classList()}
|
||||
fill="currentColor"
|
||||
>
|
||||
<For each={spinnerDots(current)}>
|
||||
{(dot) => {
|
||||
const loop = current.loop * dot.scale
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={dot.x}
|
||||
y={dot.y}
|
||||
width={dot.size}
|
||||
height={dot.size}
|
||||
rx={dot.size * 0.24}
|
||||
style={{
|
||||
"--still": dot.still,
|
||||
animation: dot.hold
|
||||
? undefined
|
||||
: `${keyframes}-${dot.shape} calc(${loop}s * var(--spinner-time, 1)) linear calc(${-loop * (1 - dot.at)}s * var(--spinner-time, 1)) infinite`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</svg>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<For each={dots}>
|
||||
{(dot) => (
|
||||
<rect
|
||||
x={dot.x}
|
||||
y={dot.y}
|
||||
width={dot.size}
|
||||
height={dot.size}
|
||||
rx={dot.size * 0.24}
|
||||
style={{
|
||||
"--still": dot.still,
|
||||
animation: dot.steady
|
||||
? undefined
|
||||
: `${keyframes}-${dot.shape} calc(${radarLoop}s * var(--spinner-time, 1)) linear calc(${-radarLoop * (1 - dot.at)}s * var(--spinner-time, 1)) infinite`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
|
||||
@@ -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 extends string, Value> = {
|
||||
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<string, string> })
|
||||
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user