+
+ )
+}
+
+const FrameTitle = (props: { title: string; kind: BrailleKind; color: string; cols: number; anim: number }) => {
+ const head = createMemo(() => getBrailleFrames(props.kind, props.cols))
+ const tail = createMemo(() => getBrailleFrames(props.kind, 64))
+ const [state, setState] = createStore({ idx: 0 })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ setState({ idx: 0 })
+ const id = window.setInterval(
+ () => {
+ setState("idx", (idx) => (idx + 1) % head().length)
+ },
+ Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
+ )
+ onCleanup(() => window.clearInterval(id))
+ })
+
+ const left = createMemo(() => head()[state.idx] ?? "")
+ const right = createMemo(() => tail()[state.idx] ?? "")
+
+ return (
+
+
+ {left()}
+
+
{props.title}
+
+ {right()}
+
+
+ )
+}
+
+const TrailTitle = (props: { title: string; kind: BrailleKind; color: string; cols: number; anim: number }) => {
+ const tail = createMemo(() => trailFrames(Math.max(24, props.cols * 12)))
+ const [state, setState] = createStore({ idx: 0 })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ setState({ idx: 0 })
+ const id = window.setInterval(
+ () => {
+ setState("idx", (idx) => (idx + 1) % tail().length)
+ },
+ Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
+ )
+ onCleanup(() => window.clearInterval(id))
+ })
+
+ return (
+
+
{props.title}
+
+ {tail()[state.idx] ?? ""}
+
+
+ )
+}
+
+const SquareWaveTitle = (props: {
+ title: string
+ color: string
+ anim: number
+ move: number
+ size: number
+ gap: number
+ low: number
+ high: number
+}) => {
+ const cols = createMemo(() => Math.max(96, Math.ceil(Array.from(props.title).length * 4.5)))
+ const cells = createMemo(() =>
+ Array.from({ length: cols() * 4 }, (_, idx) => ({
+ row: Math.floor(idx / cols()),
+ col: idx % cols(),
+ })),
+ )
+ const [state, setState] = createStore({ pos: 0, phase: 0 })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ setState({ pos: 0, phase: 0 })
+ const anim = window.setInterval(
+ () => {
+ setState("phase", (phase) => phase + 0.45)
+ },
+ Math.max(16, Math.round(44 / Math.max(0.4, props.anim))),
+ )
+ const slide = window.setInterval(
+ () => {
+ setState("pos", (pos) => (pos >= cols() + 10 ? 0 : pos + 1))
+ },
+ Math.max(40, Math.round(160 / Math.max(0.4, props.move))),
+ )
+ onCleanup(() => {
+ window.clearInterval(anim)
+ window.clearInterval(slide)
+ })
+ })
+
+ return (
+
+
+
+ {(cell) => {
+ const opacity = () => {
+ const wave = (Math.cos((cell.col - state.pos) * 0.32 - state.phase + cell.row * 0.55) + 1) / 2
+ return props.low + (props.high - props.low) * wave * wave
+ }
+
+ return (
+
+ )
+ }}
+
+
+
+ {props.title}
+
+
+ )
+}
+
+const SpinnerConcept = (props: {
+ row: Def
+ order: JSX.Element
+ controls: JSX.Element
+ children: JSX.Element
+ active: boolean
+ color: string
+ onSelect: () => void
+}) => {
+ return (
+
{
+ if (event.key !== "Enter" && event.key !== " ") return
+ event.preventDefault()
+ props.onSelect()
+ }}
+ class="w-full rounded-lg border border-border-weaker-base bg-background-stronger px-3 py-3 text-left transition-colors"
+ classList={{
+ "border-border-strong-base": props.active,
+ }}
+ >
{props.children}
+
+
+
+
+
{props.order}
{props.controls}
@@ -327,6 +687,7 @@ export function SessionSidePanel(props: {
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
+ const spinnerLab = useSpinnerLab()
const { params, sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -360,16 +721,6 @@ export function SessionSidePanel(props: {
return "session.review.noChanges"
})
const title = createMemo(() => info()?.title?.trim() || language.t("command.session.new"))
- const [tune, setTune] = createStore(
- defs.reduce
>((acc, row) => {
- acc[row.id] = {
- cols: "kind" in row ? row.cols : 3,
- speed: "kind" in row ? row.speed : 1,
- color: row.color,
- }
- return acc
- }, {}),
- )
const [spinner, setSpinner] = createStore({
order: defs.map((row) => row.id) as DefId[],
})
@@ -475,7 +826,11 @@ export function SessionSidePanel(props: {
icon="arrow-up"
variant="ghost"
class="h-6 w-6"
- onClick={() => shift(row.id, -1)}
+ onPointerDown={(event: PointerEvent) => event.stopPropagation()}
+ onClick={(event: MouseEvent) => {
+ event.stopPropagation()
+ shift(row.id, -1)
+ }}
disabled={idx === 0}
aria-label={`Move ${row.name} up`}
/>
@@ -483,7 +838,11 @@ export function SessionSidePanel(props: {
icon="arrow-down-to-line"
variant="ghost"
class="h-6 w-6"
- onClick={() => shift(row.id, 1)}
+ onPointerDown={(event: PointerEvent) => event.stopPropagation()}
+ onClick={(event: MouseEvent) => {
+ event.stopPropagation()
+ shift(row.id, 1)
+ }}
disabled={idx === spinner.order.length - 1}
aria-label={`Move ${row.name} down`}
/>
@@ -504,56 +863,138 @@ export function SessionSidePanel(props: {
{"kind" in row && (
-
-
Chars
-
setTune(row.id, "cols", (value) => clamp(value - 1, 2, 12))}
- aria-label={`Decrease ${row.name} characters`}
- >
- -
-
-
{tune[row.id].cols}
-
setTune(row.id, "cols", (value) => clamp(value + 1, 2, 12))}
- aria-label={`Increase ${row.name} characters`}
- >
- +
-
-
+
+
+
Chars
+
{spinnerLab.tune[row.id].cols}
+
+ spinnerLab.setTune(row.id, "cols", Number(event.currentTarget.value))}
+ aria-label={`${row.name} characters`}
+ />
+
)}
- {"kind" in row && (
-
-
Speed
-
setTune(row.id, "speed", (value) => clamp(Number((value - 0.2).toFixed(1)), 0.4, 4))}
- aria-label={`Decrease ${row.name} speed`}
- >
- -
-
-
{tune[row.id].speed.toFixed(1)}
-
setTune(row.id, "speed", (value) => clamp(Number((value + 0.2).toFixed(1)), 0.4, 4))}
- aria-label={`Increase ${row.name} speed`}
- >
- +
-
-
+ {adjustable(row) && (
+
+
+
Animate
+
{spinnerLab.tune[row.id].anim.toFixed(1)}
+
+ spinnerLab.setTune(row.id, "anim", Number(event.currentTarget.value))}
+ aria-label={`${row.name} animation speed`}
+ />
+
+ )}
+ {adjustable(row) && moving(row) && (
+
+
+
Move
+
{spinnerLab.tune[row.id].move.toFixed(1)}
+
+ spinnerLab.setTune(row.id, "move", Number(event.currentTarget.value))}
+ aria-label={`${row.name} movement speed`}
+ />
+
+ )}
+ {"square" in row && (
+
+
+
Square
+
{spinnerLab.tune[row.id].size}px
+
+ spinnerLab.setTune(row.id, "size", Number(event.currentTarget.value))}
+ aria-label={`${row.name} square size`}
+ />
+
+ )}
+ {"square" in row && (
+
+
+
Gap
+
{spinnerLab.tune[row.id].gap}px
+
+ spinnerLab.setTune(row.id, "gap", Number(event.currentTarget.value))}
+ aria-label={`${row.name} square gap`}
+ />
+
+ )}
+ {"square" in row && (
+
+
+
Base
+
{spinnerLab.tune[row.id].low.toFixed(2)}
+
+ spinnerLab.setTune(row.id, "low", Number(event.currentTarget.value))}
+ aria-label={`${row.name} base opacity`}
+ />
+
+ )}
+ {"square" in row && (
+
+
+
Peak
+
{spinnerLab.tune[row.id].high.toFixed(2)}
+
+ spinnerLab.setTune(row.id, "high", Number(event.currentTarget.value))}
+ aria-label={`${row.name} peak opacity`}
+ />
+
)}
Color
setTune(row.id, "color", event.currentTarget.value)}
+ onInput={(event) => spinnerLab.setTune(row.id, "color", event.currentTarget.value)}
aria-label={`${row.name} color`}
/>
@@ -571,30 +1012,75 @@ export function SessionSidePanel(props: {
Current session title with adjustable loading treatments.
- Use the arrows beside each concept to reorder the list.
+ Click a concept to try it in the session header. Use the arrows beside each concept to reorder the list.
-
+
{(row, idx) => (
-
- {"kind" in row ? (
+ selectSpinnerLab(row.id)}
+ >
+ {"square" in row ? (
+
+ ) : "kind" in row ? (
"replace" in row ? (
+ ) : "trail" in row ? (
+
+ ) : "frame" in row ? (
+
+ ) : "overlay" in row ? (
+
) : "shimmer" in row ? (
) : (
@@ -602,10 +1088,10 @@ export function SessionSidePanel(props: {
{preview(row.name)}
@@ -614,7 +1100,7 @@ export function SessionSidePanel(props: {
) : (
<>
-
+
{preview(row.name)}
>
diff --git a/packages/app/src/pages/session/spinner-lab.tsx b/packages/app/src/pages/session/spinner-lab.tsx
new file mode 100644
index 0000000000..36412dbbd9
--- /dev/null
+++ b/packages/app/src/pages/session/spinner-lab.tsx
@@ -0,0 +1,484 @@
+import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Braille, getBrailleFrames, type BrailleKind } from "@/components/pendulum"
+
+export const spinnerLabIds = [
+ "current",
+ "pendulum-sweep",
+ "pendulum",
+ "pendulum-glow",
+ "compress-sweep",
+ "compress",
+ "compress-flash",
+ "sort-sweep",
+ "sort",
+ "sort-spark",
+ "pendulum-replace",
+ "compress-replace",
+ "sort-replace",
+ "pendulum-sweep-replace",
+ "compress-flash-replace",
+ "sort-spark-replace",
+ "pendulum-glow-replace",
+ "compress-sweep-replace",
+ "sort-sweep-replace",
+ "pendulum-overlay",
+ "compress-overlay",
+ "sort-overlay",
+ "pendulum-glow-overlay",
+ "sort-spark-overlay",
+ "pendulum-frame",
+ "compress-frame",
+ "compress-tail",
+ "sort-frame",
+ "square-wave",
+] as const
+
+export type SpinnerLabId = (typeof spinnerLabIds)[number]
+
+const ids = new Set(spinnerLabIds)
+const trailFrames = (cols: number) => {
+ let s = 17
+ const rnd = () => {
+ s = (s * 1664525 + 1013904223) & 0xffffffff
+ return (s >>> 0) / 0xffffffff
+ }
+ return Array.from({ length: 120 }, () =>
+ Array.from({ length: cols }, () => {
+ let mask = 0
+ for (let bit = 0; bit < 8; bit++) {
+ if (rnd() > 0.45) mask |= 1 << bit
+ }
+ if (!mask) mask = 1 << Math.floor(rnd() * 8)
+ return String.fromCharCode(0x2800 + mask)
+ }).join(""),
+ )
+}
+
+const parse = (id: SpinnerLabId) => {
+ const kind: BrailleKind | undefined = id.startsWith("pendulum")
+ ? "pendulum"
+ : id.startsWith("compress")
+ ? "compress"
+ : id.startsWith("sort")
+ ? "sort"
+ : undefined
+ const mode =
+ id === "current"
+ ? "current"
+ : id === "square-wave"
+ ? "square"
+ : id.endsWith("-tail")
+ ? "trail"
+ : id.endsWith("-replace")
+ ? "replace"
+ : id.endsWith("-overlay")
+ ? "overlay"
+ : id.endsWith("-frame")
+ ? "frame"
+ : id === "pendulum" || id === "compress" || id === "sort"
+ ? "spin"
+ : "shimmer"
+ const anim = id.includes("glow")
+ ? 1.4
+ : id.includes("flash") || id.includes("spark")
+ ? 2.4
+ : id.includes("sweep")
+ ? 1.9
+ : 1.8
+ const move = mode === "spin" || mode === "current" ? 1 : anim
+ return {
+ id,
+ mode,
+ kind,
+ cols: mode === "spin" ? 3 : 6,
+ anim,
+ move,
+ color: "#FFE865",
+ size: 2,
+ gap: 1,
+ low: 0.08,
+ high: 0.72,
+ }
+}
+
+type SpinnerLabTune = ReturnType
+
+const defaults = Object.fromEntries(spinnerLabIds.map((id) => [id, parse(id)])) as Record
+const [lab, setLab] = createStore({ active: "pendulum" as SpinnerLabId, tune: defaults })
+
+const mask = (title: string, fill: string, pos: number) =>
+ Array.from(title)
+ .map((char, idx) => {
+ const off = idx - pos
+ if (off < 0 || off >= fill.length) return char
+ return fill[off] ?? char
+ })
+ .join("")
+
+const Shimmer = (props: {
+ title: string
+ kind: BrailleKind
+ cols: number
+ anim: number
+ move: number
+ color: string
+}) => {
+ const [x, setX] = createSignal(-18)
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ setX(-18)
+ const id = window.setInterval(() => setX((x) => (x > 112 ? -18 : x + Math.max(0.5, props.move))), 32)
+ onCleanup(() => window.clearInterval(id))
+ })
+
+ return (
+
+ )
+}
+
+const Replace = (props: {
+ title: string
+ kind: BrailleKind
+ cols: number
+ anim: number
+ move: number
+ color: string
+}) => {
+ const chars = createMemo(() => Array.from(props.title))
+ const frames = createMemo(() => getBrailleFrames(props.kind, props.cols))
+ const [state, setState] = createStore({ pos: 0, idx: 0 })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ setState({ pos: 0, idx: 0 })
+ const anim = window.setInterval(
+ () => setState("idx", (idx) => (idx + 1) % frames().length),
+ Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
+ )
+ const move = window.setInterval(
+ () => setState("pos", (pos) => (pos >= chars().length - 1 ? 0 : pos + 1)),
+ Math.max(90, Math.round(260 / Math.max(0.4, props.move))),
+ )
+ onCleanup(() => {
+ window.clearInterval(anim)
+ window.clearInterval(move)
+ })
+ })
+
+ return (
+
+ {mask(props.title, frames()[state.idx] ?? "", state.pos)}
+
+ )
+}
+
+const Overlay = (props: {
+ title: string
+ kind: BrailleKind
+ cols: number
+ anim: number
+ move: number
+ color: string
+}) => {
+ let root: HTMLDivElement | undefined
+ let fx: HTMLDivElement | undefined
+ const [state, setState] = createStore({ pos: 0, max: 0, dark: false })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ setState({ pos: 0 })
+ const id = window.setInterval(
+ () => setState("pos", (pos) => (pos >= state.max ? 0 : Math.min(state.max, pos + 8))),
+ Math.max(90, Math.round(260 / Math.max(0.4, props.move))),
+ )
+ onCleanup(() => window.clearInterval(id))
+ })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ if (!root || !fx) return
+ const sync = () => setState("max", Math.max(0, root!.clientWidth - fx!.clientWidth))
+ sync()
+ const observer = new ResizeObserver(sync)
+ observer.observe(root)
+ observer.observe(fx)
+ onCleanup(() => observer.disconnect())
+ })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ const query = window.matchMedia("(prefers-color-scheme: dark)")
+ const sync = () => setState("dark", query.matches)
+ sync()
+ query.addEventListener("change", sync)
+ onCleanup(() => query.removeEventListener("change", sync))
+ })
+
+ return (
+
+ )
+}
+
+const Frame = (props: { title: string; kind: BrailleKind; cols: number; anim: number; color: string }) => {
+ const head = createMemo(() => getBrailleFrames(props.kind, props.cols))
+ const tail = createMemo(() => getBrailleFrames(props.kind, 64))
+ const [state, setState] = createStore({ idx: 0 })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ setState({ idx: 0 })
+ const id = window.setInterval(
+ () => setState("idx", (idx) => (idx + 1) % head().length),
+ Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
+ )
+ onCleanup(() => window.clearInterval(id))
+ })
+
+ return (
+
+
+ {head()[state.idx] ?? ""}
+
+
{props.title}
+
+ {tail()[state.idx] ?? ""}
+
+
+ )
+}
+
+const Trail = (props: { title: string; kind: BrailleKind; cols: number; anim: number; color: string }) => {
+ const tail = createMemo(() => trailFrames(Math.max(24, props.cols * 12)))
+ const [state, setState] = createStore({ idx: 0 })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ setState({ idx: 0 })
+ const id = window.setInterval(
+ () => setState("idx", (idx) => (idx + 1) % tail().length),
+ Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
+ )
+ onCleanup(() => window.clearInterval(id))
+ })
+
+ return (
+
+
{props.title}
+
+ {tail()[state.idx] ?? ""}
+
+
+ )
+}
+
+const Square = (props: {
+ title: string
+ anim: number
+ move: number
+ color: string
+ size: number
+ gap: number
+ low: number
+ high: number
+}) => {
+ const cols = createMemo(() => Math.max(96, Math.ceil(Array.from(props.title).length * 4.5)))
+ const cells = createMemo(() =>
+ Array.from({ length: cols() * 4 }, (_, idx) => ({ row: Math.floor(idx / cols()), col: idx % cols() })),
+ )
+ const [state, setState] = createStore({ pos: 0, phase: 0 })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ setState({ pos: 0, phase: 0 })
+ const anim = window.setInterval(
+ () => setState("phase", (phase) => phase + 0.45),
+ Math.max(16, Math.round(44 / Math.max(0.4, props.anim))),
+ )
+ const move = window.setInterval(
+ () => setState("pos", (pos) => (pos >= cols() + 10 ? 0 : pos + 1)),
+ Math.max(40, Math.round(160 / Math.max(0.4, props.move))),
+ )
+ onCleanup(() => {
+ window.clearInterval(anim)
+ window.clearInterval(move)
+ })
+ })
+
+ return (
+
+
+
+ {(cell) => {
+ const opacity = () => {
+ const wave = (Math.cos((cell.col - state.pos) * 0.32 - state.phase + cell.row * 0.55) + 1) / 2
+ return props.low + (props.high - props.low) * wave * wave
+ }
+ return (
+
+ )
+ }}
+
+
+
+ {props.title}
+
+
+ )
+}
+
+export const selectSpinnerLab = (id: string) => {
+ if (!ids.has(id)) return
+ setLab("active", id as SpinnerLabId)
+}
+
+export const useSpinnerLab = () => ({
+ active: () => lab.active,
+ isActive: (id: string) => lab.active === id,
+ tune: lab.tune,
+ config: (id: SpinnerLabId) => lab.tune[id],
+ current: () => lab.tune[lab.active],
+ setTune: (id: SpinnerLabId, key: K, value: SpinnerLabTune[K]) =>
+ setLab("tune", id, key, value),
+})
+
+export function SpinnerLabHeader(props: { title: string; tint?: string; class?: string }) {
+ const cfg = createMemo(() => lab.tune[lab.active])
+ const body = createMemo(() => {
+ const cur = cfg()
+
+ if (cur.mode === "current") {
+ return (
+
+ )
+ }
+
+ if (cur.mode === "spin" && cur.kind) {
+ return (
+
+ )
+ }
+
+ if (cur.mode === "shimmer" && cur.kind) {
+ return (
+
+ )
+ }
+
+ if (cur.mode === "replace" && cur.kind) {
+ return (
+
+ )
+ }
+
+ if (cur.mode === "overlay" && cur.kind) {
+ return (
+
+ )
+ }
+
+ if (cur.mode === "trail" && cur.kind) {
+ return
+ }
+
+ if (cur.mode === "frame" && cur.kind) {
+ return
+ }
+
+ return (
+
+ )
+ })
+
+ return {body()}
+}