more concepts

spinner-concepts
David Hill 2026-03-26 12:24:19 +00:00
parent 431aca1df9
commit 5c2960a0d8
4 changed files with 1098 additions and 127 deletions

View File

@ -9,12 +9,12 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { Pendulum } from "@/components/pendulum"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { SpinnerLabHeader } from "@/pages/session/spinner-lab"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers"
@ -115,29 +115,36 @@ const SessionRow = (props: {
props.clearHoverProjectSoon()
}}
>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
<Show
when={props.isWorking()}
fallback={
<>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</>
}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Pendulum
class="inline-flex w-full items-center justify-center overflow-hidden font-mono text-[9px] leading-none text-current select-none"
cols={3}
/>
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
<SpinnerLabHeader
title={props.session.title}
tint={props.tint() ?? "var(--icon-interactive-base)"}
class="min-w-0 flex-1"
/>
</Show>
</A>
)

View File

@ -17,8 +17,8 @@ import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { Pendulum } from "@/components/pendulum"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SpinnerLabHeader } from "@/pages/session/spinner-lab"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
@ -657,37 +657,31 @@ export function MessageTimeline(props: {
/>
</Show>
<div class="flex items-center min-w-0 grow-1">
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: working() ? "16px" : "0px",
"margin-right": working() ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={workingStatus() !== "hidden"}>
<div
class="transition-opacity duration-200 ease-out"
classList={{ "opacity-0": workingStatus() === "hiding" }}
>
<Pendulum
cols={2}
class="inline-flex w-4 items-center justify-center overflow-hidden font-mono text-[9px] leading-none select-none"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
/>
</div>
</Show>
</div>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
<Show
when={workingStatus() !== "hidden"}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
>
{titleValue()}
</h1>
}
>
{titleValue()}
</h1>
<div
class="min-w-0 grow-1 transition-opacity duration-200 ease-out"
classList={{ "opacity-0": workingStatus() === "hiding" }}
>
<SpinnerLabHeader
title={titleValue() ?? ""}
tint={tint() ?? "var(--icon-interactive-base)"}
/>
</div>
</Show>
}
>
<InlineInput

View File

@ -2,6 +2,7 @@ import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCle
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
@ -28,6 +29,7 @@ import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { selectSpinnerLab, useSpinnerLab } from "@/pages/session/spinner-lab"
const fixedTabs = ["spinners"]
const defs = [
@ -210,6 +212,108 @@ const defs = [
cols: 6,
speed: 1.8,
},
{
id: "pendulum-overlay",
name: "Pendulum Overlay",
note: "Wave pass rides directly on top of the title text",
kind: "pendulum" as const,
color: "#FFE865",
overlay: true,
cols: 6,
speed: 1.9,
},
{
id: "compress-overlay",
name: "Compress Overlay",
note: "Compression shimmer glides over the title without replacing it",
kind: "compress" as const,
color: "#FFE865",
overlay: true,
cols: 6,
speed: 2,
},
{
id: "sort-overlay",
name: "Sort Overlay",
note: "Settling sort shimmer passes over the title text",
kind: "sort" as const,
color: "#FFE865",
overlay: true,
cols: 6,
speed: 1.8,
},
{
id: "pendulum-glow-overlay",
name: "Pendulum Glow Overlay",
note: "Softer pendulum pass layered directly over the title",
kind: "pendulum" as const,
color: "#FFE865",
overlay: true,
cols: 6,
speed: 1.4,
},
{
id: "sort-spark-overlay",
name: "Sort Spark Overlay",
note: "Brighter noisy pass that floats over the title text",
kind: "sort" as const,
color: "#FFE865",
overlay: true,
cols: 6,
speed: 2.4,
},
{
id: "pendulum-frame",
name: "Pendulum Frame",
note: "A pendulum spinner sits before the title and fills the rest of the row after it",
kind: "pendulum" as const,
color: "#FFE865",
frame: true,
cols: 6,
speed: 1.8,
},
{
id: "compress-frame",
name: "Compress Frame",
note: "A compression spinner brackets the title with a long animated tail",
kind: "compress" as const,
color: "#FFE865",
frame: true,
cols: 6,
speed: 1.9,
},
{
id: "compress-tail",
name: "Compress Tail",
note: "A continuous compress spinner starts after the title and fills the rest of the row",
kind: "compress" as const,
color: "#FFE865",
trail: true,
cols: 6,
speed: 1.9,
},
{
id: "sort-frame",
name: "Sort Frame",
note: "A noisy sort spinner wraps the title with a giant animated frame",
kind: "sort" as const,
color: "#FFE865",
frame: true,
cols: 6,
speed: 1.8,
},
{
id: "square-wave",
name: "Square Wave",
note: "A 4-row field of tiny squares shimmers behind the entire title row",
color: "#FFE865",
square: true,
speed: 1.8,
size: 2,
gap: 1,
low: 0.08,
high: 0.72,
},
] as const
type Def = (typeof defs)[number]
@ -218,6 +322,25 @@ type DefId = Def["id"]
const defsById = new Map(defs.map((row) => [row.id, row]))
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const adjustable = (row: Def) => "kind" in row || "square" in row
const moving = (row: Def) => "shimmer" in row || "replace" in row || "overlay" in row || "square" in row
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 SpinnerTitle = (props: {
title: string
@ -225,7 +348,8 @@ const SpinnerTitle = (props: {
color: string
fx?: string
cols: number
rate: number
anim: number
move: number
}) => {
const [x, setX] = createSignal(-18)
@ -233,7 +357,7 @@ const SpinnerTitle = (props: {
if (typeof window === "undefined") return
setX(-18)
const id = window.setInterval(() => {
setX((x) => (x > 112 ? -18 : x + Math.max(0.5, props.rate)))
setX((x) => (x > 112 ? -18 : x + Math.max(0.5, props.move)))
}, 32)
onCleanup(() => window.clearInterval(id))
})
@ -246,7 +370,7 @@ const SpinnerTitle = (props: {
<Braille
kind={props.kind}
cols={props.cols}
rate={props.rate}
rate={props.anim}
class={`inline-flex items-center justify-center overflow-hidden font-mono text-[12px] leading-none font-semibold opacity-80 drop-shadow-[0_0_10px_currentColor] select-none ${props.fx ?? ""}`}
style={{ color: props.color }}
/>
@ -256,7 +380,14 @@ const SpinnerTitle = (props: {
)
}
const ReplaceTitle = (props: { title: string; kind: BrailleKind; color: string; cols: number; rate: number }) => {
const ReplaceTitle = (props: {
title: string
kind: BrailleKind
color: string
cols: number
anim: number
move: number
}) => {
const chars = createMemo(() => Array.from(props.title))
const frames = createMemo(() => getBrailleFrames(props.kind, props.cols).map((frame) => Array.from(frame)))
const [state, setState] = createStore({ pos: 0, idx: 0 })
@ -268,13 +399,13 @@ const ReplaceTitle = (props: { title: string; kind: BrailleKind; color: string;
() => {
setState("idx", (idx) => (idx + 1) % frames().length)
},
Math.max(16, Math.round(42 / Math.max(0.4, props.rate))),
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
)
const slide = window.setInterval(
() => {
setState("pos", (pos) => (pos >= chars().length - 1 ? 0 : pos + 1))
},
Math.max(90, Math.round(260 / Math.max(0.4, props.rate))),
Math.max(90, Math.round(260 / Math.max(0.4, props.move))),
)
onCleanup(() => {
window.clearInterval(anim)
@ -302,11 +433,240 @@ const ReplaceTitle = (props: { title: string; kind: BrailleKind; color: string;
)
}
const SpinnerConcept = (props: { row: Def; order: JSX.Element; controls: JSX.Element; children: JSX.Element }) => {
const OverlayTitle = (props: {
title: string
kind: BrailleKind
color: string
cols: number
anim: number
move: number
}) => {
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 (
<div class="rounded-lg border border-border-weaker-base bg-background-stronger px-3 py-3">
<div ref={root} class="relative min-w-0 flex-1 overflow-hidden py-0.5">
<div class="truncate whitespace-nowrap text-14-medium text-text-strong">{props.title}</div>
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div ref={fx} class="absolute top-1/2 -translate-y-1/2" style={{ left: `${state.pos}px` }}>
<Braille
kind={props.kind}
cols={props.cols}
rate={props.anim}
class="inline-flex items-center justify-center overflow-hidden rounded-sm px-0.5 py-2 font-mono text-[12px] leading-none font-semibold select-none"
style={{ color: props.color, "background-color": state.dark ? "#151515" : "#FCFCFC" }}
/>
</div>
</div>
</div>
)
}
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 (
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
<div class="shrink-0 font-mono text-[12px] font-semibold leading-none" style={{ color: props.color }}>
{left()}
</div>
<div class="shrink-0 truncate text-14-medium text-text-strong">{props.title}</div>
<div
class="min-w-0 flex-1 overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
style={{ color: props.color }}
>
{right()}
</div>
</div>
)
}
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 (
<div class="flex w-full min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
<div class="min-w-0 max-w-[55%] flex-[0_1_auto] truncate text-14-medium text-text-strong">{props.title}</div>
<div
class="min-w-[10ch] basis-0 flex-[1_1_0%] overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
style={{ color: props.color }}
>
{tail()[state.idx] ?? ""}
</div>
</div>
)
}
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 (
<div class="relative min-w-0 flex-1 overflow-hidden py-2">
<div
class="pointer-events-none absolute inset-0 grid content-center overflow-hidden"
aria-hidden="true"
style={{
"grid-template-columns": `repeat(${cols()}, ${props.size}px)`,
"grid-auto-rows": `${props.size}px`,
gap: `${props.gap}px`,
}}
>
<For each={cells()}>
{(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 (
<div
style={{
width: `${props.size}px`,
height: `${props.size}px`,
"background-color": props.color,
opacity: `${opacity()}`,
}}
/>
)
}}
</For>
</div>
<div class="relative z-10 truncate px-2 text-14-medium text-text-strong">
<span class="bg-background-stronger">{props.title}</span>
</div>
</div>
)
}
const SpinnerConcept = (props: {
row: Def
order: JSX.Element
controls: JSX.Element
children: JSX.Element
active: boolean
color: string
onSelect: () => void
}) => {
return (
<div
role="button"
tabIndex={0}
onClick={props.onSelect}
onKeyDown={(event) => {
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,
}}
>
<div class="flex min-w-0 items-center gap-3 px-3 py-2">
<div class="flex min-w-0 flex-1 items-center gap-3">{props.children}</div>
<Show when={props.active}>
<div class="shrink-0">
<Icon name="check" size="small" style={{ color: props.color }} />
</div>
</Show>
<div class="shrink-0">{props.order}</div>
<div class="ml-auto shrink-0">{props.controls}</div>
</div>
@ -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<Record<string, { cols: number; speed: number; color: string }>>((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: {
<DropdownMenu.Content class="w-52 p-2">
<div class="flex flex-col gap-2">
{"kind" in row && (
<div class="flex items-center gap-2 rounded-md border border-border-weaker-base px-2 py-1">
<div class="text-11-regular text-text-weaker">Chars</div>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded text-text-weak hover:bg-surface-panel"
onClick={() => setTune(row.id, "cols", (value) => clamp(value - 1, 2, 12))}
aria-label={`Decrease ${row.name} characters`}
>
-
</button>
<div class="w-7 text-center text-11-regular text-text-strong">{tune[row.id].cols}</div>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded text-text-weak hover:bg-surface-panel"
onClick={() => setTune(row.id, "cols", (value) => clamp(value + 1, 2, 12))}
aria-label={`Increase ${row.name} characters`}
>
+
</button>
</div>
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Chars</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].cols}</div>
</div>
<input
type="range"
min="2"
max="12"
step="1"
value={spinnerLab.tune[row.id].cols}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "cols", Number(event.currentTarget.value))}
aria-label={`${row.name} characters`}
/>
</label>
)}
{"kind" in row && (
<div class="flex items-center gap-2 rounded-md border border-border-weaker-base px-2 py-1">
<div class="text-11-regular text-text-weaker">Speed</div>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded text-text-weak hover:bg-surface-panel"
onClick={() => setTune(row.id, "speed", (value) => clamp(Number((value - 0.2).toFixed(1)), 0.4, 4))}
aria-label={`Decrease ${row.name} speed`}
>
-
</button>
<div class="w-9 text-center text-11-regular text-text-strong">{tune[row.id].speed.toFixed(1)}</div>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded text-text-weak hover:bg-surface-panel"
onClick={() => setTune(row.id, "speed", (value) => clamp(Number((value + 0.2).toFixed(1)), 0.4, 4))}
aria-label={`Increase ${row.name} speed`}
>
+
</button>
</div>
{adjustable(row) && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Animate</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].anim.toFixed(1)}</div>
</div>
<input
type="range"
min="0.4"
max="12"
step="0.1"
value={spinnerLab.tune[row.id].anim}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "anim", Number(event.currentTarget.value))}
aria-label={`${row.name} animation speed`}
/>
</label>
)}
{adjustable(row) && moving(row) && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Move</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].move.toFixed(1)}</div>
</div>
<input
type="range"
min="0.4"
max="12"
step="0.1"
value={spinnerLab.tune[row.id].move}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "move", Number(event.currentTarget.value))}
aria-label={`${row.name} movement speed`}
/>
</label>
)}
{"square" in row && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Square</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].size}px</div>
</div>
<input
type="range"
min="1"
max="6"
step="1"
value={spinnerLab.tune[row.id].size}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "size", Number(event.currentTarget.value))}
aria-label={`${row.name} square size`}
/>
</label>
)}
{"square" in row && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Gap</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].gap}px</div>
</div>
<input
type="range"
min="0"
max="4"
step="1"
value={spinnerLab.tune[row.id].gap}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "gap", Number(event.currentTarget.value))}
aria-label={`${row.name} square gap`}
/>
</label>
)}
{"square" in row && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Base</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].low.toFixed(2)}</div>
</div>
<input
type="range"
min="0"
max="0.6"
step="0.01"
value={spinnerLab.tune[row.id].low}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "low", Number(event.currentTarget.value))}
aria-label={`${row.name} base opacity`}
/>
</label>
)}
{"square" in row && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Peak</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].high.toFixed(2)}</div>
</div>
<input
type="range"
min="0.2"
max="1"
step="0.01"
value={spinnerLab.tune[row.id].high}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "high", Number(event.currentTarget.value))}
aria-label={`${row.name} peak opacity`}
/>
</label>
)}
<label class="flex items-center gap-2 rounded-md border border-border-weaker-base px-2 py-1">
<div class="text-11-regular text-text-weaker">Color</div>
<input
type="color"
value={tune[row.id].color}
value={spinnerLab.tune[row.id].color}
class="ml-auto h-5 w-7 cursor-pointer rounded border-none bg-transparent p-0"
onInput={(event) => setTune(row.id, "color", event.currentTarget.value)}
onInput={(event) => spinnerLab.setTune(row.id, "color", event.currentTarget.value)}
aria-label={`${row.name} color`}
/>
</label>
@ -571,30 +1012,75 @@ export function SessionSidePanel(props: {
Current session title with adjustable loading treatments.
</div>
<div class="mt-1 text-11-regular text-text-weaker">
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.
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3">
<div class="min-h-0 flex-1 overflow-y-auto px-3 pt-3 pb-[200px]">
<div class="flex flex-col gap-2">
<For each={rows()}>
{(row, idx) => (
<SpinnerConcept row={row} order={order(row, idx())} controls={controls(row, idx())}>
{"kind" in row ? (
<SpinnerConcept
row={row}
order={order(row, idx())}
controls={controls(row, idx())}
active={spinnerLab.isActive(row.id)}
color={spinnerLab.tune[row.id].color}
onSelect={() => selectSpinnerLab(row.id)}
>
{"square" in row ? (
<SquareWaveTitle
title={preview(row.name)}
color={spinnerLab.tune[row.id].color}
anim={spinnerLab.tune[row.id].anim}
move={spinnerLab.tune[row.id].move}
size={spinnerLab.tune[row.id].size}
gap={spinnerLab.tune[row.id].gap}
low={Math.min(spinnerLab.tune[row.id].low, spinnerLab.tune[row.id].high - 0.05)}
high={Math.max(spinnerLab.tune[row.id].high, spinnerLab.tune[row.id].low + 0.05)}
/>
) : "kind" in row ? (
"replace" in row ? (
<ReplaceTitle
title={preview(row.name)}
kind={row.kind}
cols={tune[row.id].cols}
rate={tune[row.id].speed}
color={tune[row.id].color}
cols={spinnerLab.tune[row.id].cols}
anim={spinnerLab.tune[row.id].anim}
move={spinnerLab.tune[row.id].move}
color={spinnerLab.tune[row.id].color}
/>
) : "trail" in row ? (
<TrailTitle
title={preview(row.name)}
kind={row.kind}
cols={spinnerLab.tune[row.id].cols}
anim={spinnerLab.tune[row.id].anim}
color={spinnerLab.tune[row.id].color}
/>
) : "frame" in row ? (
<FrameTitle
title={preview(row.name)}
kind={row.kind}
cols={spinnerLab.tune[row.id].cols}
anim={spinnerLab.tune[row.id].anim}
color={spinnerLab.tune[row.id].color}
/>
) : "overlay" in row ? (
<OverlayTitle
title={preview(row.name)}
kind={row.kind}
cols={spinnerLab.tune[row.id].cols}
anim={spinnerLab.tune[row.id].anim}
move={spinnerLab.tune[row.id].move}
color={spinnerLab.tune[row.id].color}
/>
) : "shimmer" in row ? (
<SpinnerTitle
title={preview(row.name)}
kind={row.kind}
cols={tune[row.id].cols}
rate={tune[row.id].speed}
color={tune[row.id].color}
cols={spinnerLab.tune[row.id].cols}
anim={spinnerLab.tune[row.id].anim}
move={spinnerLab.tune[row.id].move}
color={spinnerLab.tune[row.id].color}
fx={"fx" in row ? row.fx : undefined}
/>
) : (
@ -602,10 +1088,10 @@ export function SessionSidePanel(props: {
<div class="flex h-6 w-6 shrink-0 items-center justify-center">
<Braille
kind={row.kind}
cols={tune[row.id].cols}
rate={tune[row.id].speed}
cols={spinnerLab.tune[row.id].cols}
rate={spinnerLab.tune[row.id].anim}
class="inline-flex w-5 items-center justify-center overflow-hidden font-mono text-[11px] leading-none font-semibold select-none"
style={{ color: tune[row.id].color }}
style={{ color: spinnerLab.tune[row.id].color }}
/>
</div>
<div class="min-w-0 truncate text-14-medium text-text-strong">{preview(row.name)}</div>
@ -614,7 +1100,7 @@ export function SessionSidePanel(props: {
) : (
<>
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
<Spinner class="size-4" style={{ color: tune[row.id].color }} />
<Spinner class="size-4" style={{ color: spinnerLab.tune[row.id].color }} />
</div>
<div class="min-w-0 truncate text-14-medium text-text-strong">{preview(row.name)}</div>
</>

View File

@ -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<string>(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<typeof parse>
const defaults = Object.fromEntries(spinnerLabIds.map((id) => [id, parse(id)])) as Record<SpinnerLabId, SpinnerLabTune>
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 (
<div class="relative min-w-0 flex-1 overflow-hidden py-0.5">
<div class="truncate text-14-medium text-text-strong">{props.title}</div>
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div class="absolute top-1/2 -translate-y-1/2" style={{ left: `calc(${x()}% - 6ch)` }}>
<Braille
kind={props.kind}
cols={props.cols}
rate={props.anim}
class="inline-flex items-center justify-center overflow-hidden font-mono text-[12px] leading-none font-semibold opacity-80 select-none"
style={{ color: props.color }}
/>
</div>
</div>
</div>
)
}
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 (
<div class="min-w-0 truncate whitespace-nowrap font-mono text-[13px] font-semibold text-text-strong">
{mask(props.title, frames()[state.idx] ?? "", state.pos)}
</div>
)
}
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 (
<div ref={root} class="relative min-w-0 flex-1 overflow-hidden py-0.5">
<div class="truncate text-14-medium text-text-strong">{props.title}</div>
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div ref={fx} class="absolute top-1/2 -translate-y-1/2" style={{ left: `${state.pos}px` }}>
<Braille
kind={props.kind}
cols={props.cols}
rate={props.anim}
class="inline-flex items-center justify-center overflow-hidden rounded-sm px-0.5 py-2 font-mono text-[12px] leading-none font-semibold select-none"
style={{ color: props.color, "background-color": state.dark ? "#151515" : "#FCFCFC" }}
/>
</div>
</div>
</div>
)
}
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 (
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
<div class="shrink-0 font-mono text-[12px] font-semibold leading-none" style={{ color: props.color }}>
{head()[state.idx] ?? ""}
</div>
<div class="shrink-0 truncate text-14-medium text-text-strong">{props.title}</div>
<div
class="min-w-0 flex-1 overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
style={{ color: props.color }}
>
{tail()[state.idx] ?? ""}
</div>
</div>
)
}
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 (
<div class="flex w-full min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
<div class="min-w-0 max-w-[55%] flex-[0_1_auto] truncate text-14-medium text-text-strong">{props.title}</div>
<div
class="min-w-[10ch] basis-0 flex-[1_1_0%] overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
style={{ color: props.color }}
>
{tail()[state.idx] ?? ""}
</div>
</div>
)
}
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 (
<div class="relative min-w-0 flex-1 overflow-hidden py-2">
<div
class="pointer-events-none absolute inset-0 grid content-center overflow-hidden"
aria-hidden="true"
style={{
"grid-template-columns": `repeat(${cols()}, ${props.size}px)`,
"grid-auto-rows": `${props.size}px`,
gap: `${props.gap}px`,
}}
>
<For each={cells()}>
{(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 (
<div
style={{
width: `${props.size}px`,
height: `${props.size}px`,
"background-color": props.color,
opacity: `${opacity()}`,
}}
/>
)
}}
</For>
</div>
<div class="relative z-10 truncate px-2 text-14-medium text-text-strong">
<span class="bg-background-stronger">{props.title}</span>
</div>
</div>
)
}
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: <K extends keyof SpinnerLabTune>(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<JSX.Element>(() => {
const cur = cfg()
if (cur.mode === "current") {
return (
<div class="flex min-w-0 items-center gap-2">
<Spinner class="size-4" style={{ color: props.tint ?? cur.color }} />
<div class="min-w-0 truncate text-14-medium text-text-strong">{props.title}</div>
</div>
)
}
if (cur.mode === "spin" && cur.kind) {
return (
<div class="flex min-w-0 items-center gap-2">
<Braille
kind={cur.kind}
cols={cur.cols}
rate={cur.anim}
class="inline-flex w-4 items-center justify-center overflow-hidden font-mono text-[9px] leading-none select-none"
style={{ color: cur.color }}
/>
<div class="min-w-0 truncate text-14-medium text-text-strong">{props.title}</div>
</div>
)
}
if (cur.mode === "shimmer" && cur.kind) {
return (
<Shimmer
title={props.title}
kind={cur.kind}
cols={cur.cols}
anim={cur.anim}
move={cur.move}
color={cur.color}
/>
)
}
if (cur.mode === "replace" && cur.kind) {
return (
<Replace
title={props.title}
kind={cur.kind}
cols={cur.cols}
anim={cur.anim}
move={cur.move}
color={cur.color}
/>
)
}
if (cur.mode === "overlay" && cur.kind) {
return (
<Overlay
title={props.title}
kind={cur.kind}
cols={cur.cols}
anim={cur.anim}
move={cur.move}
color={cur.color}
/>
)
}
if (cur.mode === "trail" && cur.kind) {
return <Trail title={props.title} kind={cur.kind} cols={cur.cols} anim={cur.anim} color={cur.color} />
}
if (cur.mode === "frame" && cur.kind) {
return <Frame title={props.title} kind={cur.kind} cols={cur.cols} anim={cur.anim} color={cur.color} />
}
return (
<Square
title={props.title}
anim={cur.anim}
move={cur.move}
color={cur.color}
size={cur.size}
gap={cur.gap}
low={cur.low}
high={cur.high}
/>
)
})
return <div class={props.class ?? "min-w-0 grow-1 w-full"}>{body()}</div>
}