Compare commits

...

3 Commits

Author SHA1 Message Date
David Hill
5c2960a0d8 more concepts 2026-03-26 12:24:19 +00:00
David Hill
431aca1df9 unsure 2026-03-25 14:19:03 +00:00
David Hill
c25077c2e5 feat(app): add session spinner concept lab
Let the desktop app preview and tune alternate loading treatments for session titles so spinner ideas can be explored in context before replacing the default UI.
2026-03-25 14:18:29 +00:00
9 changed files with 1779 additions and 99 deletions

View File

@@ -0,0 +1,199 @@
import type { ComponentProps } from "solid-js"
import { createEffect, createSignal, onCleanup } from "solid-js"
type Kind = "pendulum" | "compress" | "sort"
export type BrailleKind = Kind
const bits = [
[0x01, 0x08],
[0x02, 0x10],
[0x04, 0x20],
[0x40, 0x80],
]
const seeded = (seed: number) => {
let s = seed
return () => {
s = (s * 1664525 + 1013904223) & 0xffffffff
return (s >>> 0) / 0xffffffff
}
}
const pendulum = (cols: number, max = 1) => {
const total = 120
const span = cols * 2
const frames = [] as string[]
for (let t = 0; t < total; t++) {
const codes = Array.from({ length: cols }, () => 0x2800)
const p = t / total
const spread = Math.sin(Math.PI * p) * max
const phase = p * Math.PI * 8
for (let pc = 0; pc < span; pc++) {
const swing = Math.sin(phase + pc * spread)
const center = (1 - swing) * 1.5
for (let row = 0; row < 4; row++) {
if (Math.abs(row - center) >= 0.7) continue
codes[Math.floor(pc / 2)] |= bits[row][pc % 2]
}
}
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
}
return frames
}
const compress = (cols: number) => {
const total = 100
const span = cols * 2
const dots = span * 4
const frames = [] as string[]
const rand = seeded(42)
const weight = Array.from({ length: dots }, () => rand())
for (let t = 0; t < total; t++) {
const codes = Array.from({ length: cols }, () => 0x2800)
const p = t / total
const sieve = Math.max(0.1, 1 - p * 1.2)
const squeeze = Math.min(1, p / 0.85)
const active = Math.max(1, span * (1 - squeeze * 0.95))
for (let pc = 0; pc < span; pc++) {
const map = (pc / span) * active
if (map >= active) continue
const next = Math.round(map)
if (next >= span) continue
const char = Math.floor(next / 2)
const dot = next % 2
for (let row = 0; row < 4; row++) {
if (weight[pc * 4 + row] >= sieve) continue
codes[char] |= bits[row][dot]
}
}
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
}
return frames
}
const sort = (cols: number) => {
const span = cols * 2
const total = 100
const frames = [] as string[]
const rand = seeded(19)
const start = Array.from({ length: span }, () => rand() * 3)
const end = Array.from({ length: span }, (_, i) => (i / Math.max(1, span - 1)) * 3)
for (let t = 0; t < total; t++) {
const codes = Array.from({ length: cols }, () => 0x2800)
const p = t / total
const cursor = p * span * 1.2
for (let pc = 0; pc < span; pc++) {
const char = Math.floor(pc / 2)
const dot = pc % 2
const delta = pc - cursor
let center
if (delta < -3) {
center = end[pc]
} else if (delta < 2) {
const blend = 1 - (delta + 3) / 5
const ease = blend * blend * (3 - 2 * blend)
center = start[pc] + (end[pc] - start[pc]) * ease
if (Math.abs(delta) < 0.8) {
for (let row = 0; row < 4; row++) codes[char] |= bits[row][dot]
continue
}
} else {
center = start[pc] + Math.sin(p * Math.PI * 16 + pc * 2.7) * 0.6 + Math.sin(p * Math.PI * 9 + pc * 1.3) * 0.4
}
center = Math.max(0, Math.min(3, center))
for (let row = 0; row < 4; row++) {
if (Math.abs(row - center) >= 0.7) continue
codes[char] |= bits[row][dot]
}
}
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
}
return frames
}
const build = (kind: Kind, cols: number) => {
if (kind === "compress") return compress(cols)
if (kind === "sort") return sort(cols)
return pendulum(cols)
}
const pace = (kind: Kind) => {
if (kind === "pendulum") return 16
return 40
}
const cache = new Map<string, string[]>()
const get = (kind: Kind, cols: number) => {
const key = `${kind}:${cols}`
const saved = cache.get(key)
if (saved) return saved
const made = build(kind, cols)
cache.set(key, made)
return made
}
export const getBrailleFrames = (kind: Kind, cols: number) => get(kind, cols)
export function Braille(props: {
kind?: Kind
cols?: number
rate?: number
class?: string
classList?: ComponentProps<"span">["classList"]
style?: ComponentProps<"span">["style"]
label?: string
}) {
const kind = () => props.kind ?? "pendulum"
const cols = () => props.cols ?? 2
const rate = () => props.rate ?? 1
const [idx, setIdx] = createSignal(0)
createEffect(() => {
if (typeof window === "undefined") return
const frames = get(kind(), cols())
setIdx(0)
const id = window.setInterval(
() => {
setIdx((idx) => (idx + 1) % frames.length)
},
Math.max(10, Math.round(pace(kind()) / rate())),
)
onCleanup(() => window.clearInterval(id))
})
return (
<span
role="status"
aria-label={props.label ?? "Loading"}
class={props.class}
classList={props.classList}
style={props.style}
>
<span aria-hidden="true">{get(kind(), cols())[idx()]}</span>
</span>
)
}
export function Pendulum(props: Omit<Parameters<typeof Braille>[0], "kind">) {
return <Braille {...props} kind="pendulum" />
}

View File

@@ -4,7 +4,6 @@ import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
@@ -15,6 +14,7 @@ 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,26 +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()}>
<Spinner class="size-[15px]" />
</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

@@ -15,6 +15,7 @@ type TabsInput = {
normalizeTab: (tab: string) => string
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fixed?: Accessor<string[]>
}
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
@@ -22,6 +23,7 @@ export const getSessionKey = (dir: string | undefined, id: string | undefined) =
export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const fixed = input.fixed ?? (() => emptyTabs)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openedTabs = createMemo(
() => {
@@ -30,7 +32,7 @@ export const createSessionTabs = (input: TabsInput) => {
.tabs()
.all()
.flatMap((tab) => {
if (tab === "context" || tab === "review") return []
if (tab === "context" || tab === "review" || fixed().includes(tab)) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
seen.add(value)
@@ -44,6 +46,7 @@ export const createSessionTabs = (input: TabsInput) => {
const active = input.tabs().active()
if (active === "context") return active
if (active === "review" && review()) return active
if (active && fixed().includes(active)) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
const first = openedTabs()[0]
@@ -60,6 +63,7 @@ export const createSessionTabs = (input: TabsInput) => {
const closableTab = createMemo(() => {
const active = activeTab()
if (active === "context") return active
if (fixed().includes(active)) return
if (!openedTabs().includes(active)) return
return active
})

View File

@@ -9,7 +9,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { TextField } from "@opencode-ai/ui/text-field"
@@ -19,6 +18,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
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,33 +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" }}
>
<Spinner class="size-4" 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

File diff suppressed because it is too large Load Diff

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

View File

@@ -133,9 +133,9 @@
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"opencode-poe-auth": "0.0.1",
"remeda": "catalog:",
"semver": "^7.6.3",
"solid-js": "catalog:",

View File

@@ -0,0 +1,2 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>

File diff suppressed because one or more lines are too long