Explorar o código

more concepts

David Hill hai 3 semanas
pai
achega
5c2960a0d8

+ 30 - 23
packages/app/src/pages/layout/sidebar-items.tsx

@@ -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>
 )
 

+ 21 - 27
packages/app/src/pages/session/message-timeline.tsx

@@ -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

+ 563 - 77
packages/app/src/pages/session/session-side-panel.tsx

@@ -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>
                     </>

+ 484 - 0
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<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>
+}