Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c2960a0d8 | |||
| 431aca1df9 | |||
| c25077c2e5 |
@@ -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" />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
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"
|
||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
@@ -14,6 +17,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { Braille, getBrailleFrames, type BrailleKind } from "@/components/pendulum"
|
||||
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
@@ -25,6 +29,650 @@ 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 = [
|
||||
{ id: "current", name: "Current", note: "Existing square pulse", color: "#FFE865" },
|
||||
{
|
||||
id: "pendulum-sweep",
|
||||
name: "Pendulum Sweep",
|
||||
note: "6-char wave shimmer gliding left to right",
|
||||
kind: "pendulum" as const,
|
||||
color: "#FFE865",
|
||||
shimmer: true,
|
||||
cols: 6,
|
||||
speed: 1.8,
|
||||
},
|
||||
{
|
||||
id: "pendulum",
|
||||
name: "Pendulum",
|
||||
note: "Braille wave shimmer",
|
||||
kind: "pendulum" as const,
|
||||
color: "#FFE865",
|
||||
cols: 3,
|
||||
speed: 1,
|
||||
},
|
||||
{
|
||||
id: "pendulum-glow",
|
||||
name: "Pendulum Glow",
|
||||
note: "Slower sweep with a softer highlight",
|
||||
kind: "pendulum" as const,
|
||||
color: "#FFE865",
|
||||
fx: "opacity-55",
|
||||
shimmer: true,
|
||||
cols: 6,
|
||||
speed: 1.2,
|
||||
},
|
||||
{
|
||||
id: "compress-sweep",
|
||||
name: "Compress Sweep",
|
||||
note: "6-char shimmer that tightens as it moves",
|
||||
kind: "compress" as const,
|
||||
color: "#FFE865",
|
||||
shimmer: true,
|
||||
cols: 6,
|
||||
speed: 1.6,
|
||||
},
|
||||
{
|
||||
id: "compress",
|
||||
name: "Compress",
|
||||
note: "Tight sieve collapse",
|
||||
kind: "compress" as const,
|
||||
color: "#FFE865",
|
||||
cols: 3,
|
||||
speed: 1,
|
||||
},
|
||||
{
|
||||
id: "compress-flash",
|
||||
name: "Compress Flash",
|
||||
note: "Faster compression pass for a sharper gleam",
|
||||
kind: "compress" as const,
|
||||
color: "#FFE865",
|
||||
shimmer: true,
|
||||
cols: 6,
|
||||
speed: 2.2,
|
||||
},
|
||||
{
|
||||
id: "sort-sweep",
|
||||
name: "Sort Sweep",
|
||||
note: "6-char noisy shimmer that settles across the title",
|
||||
kind: "sort" as const,
|
||||
color: "#FFE865",
|
||||
shimmer: true,
|
||||
cols: 6,
|
||||
speed: 1.7,
|
||||
},
|
||||
{
|
||||
id: "sort",
|
||||
name: "Sort",
|
||||
note: "Noisy settle pass",
|
||||
kind: "sort" as const,
|
||||
color: "#FFE865",
|
||||
cols: 3,
|
||||
speed: 1,
|
||||
},
|
||||
{
|
||||
id: "sort-spark",
|
||||
name: "Sort Spark",
|
||||
note: "Brighter pass with more glitch energy",
|
||||
kind: "sort" as const,
|
||||
color: "#FFE865",
|
||||
shimmer: true,
|
||||
cols: 6,
|
||||
speed: 2.4,
|
||||
},
|
||||
{
|
||||
id: "pendulum-replace",
|
||||
name: "Pendulum Replace",
|
||||
note: "Braille sweep temporarily replaces title characters",
|
||||
kind: "pendulum" as const,
|
||||
color: "#FFE865",
|
||||
replace: true,
|
||||
cols: 6,
|
||||
speed: 1.7,
|
||||
},
|
||||
{
|
||||
id: "compress-replace",
|
||||
name: "Compress Replace",
|
||||
note: "Compressed pass swaps letters as it crosses the title",
|
||||
kind: "compress" as const,
|
||||
color: "#FFE865",
|
||||
replace: true,
|
||||
cols: 6,
|
||||
speed: 1.8,
|
||||
},
|
||||
{
|
||||
id: "sort-replace",
|
||||
name: "Sort Replace",
|
||||
note: "Noisy replacement shimmer that settles as it moves",
|
||||
kind: "sort" as const,
|
||||
color: "#FFE865",
|
||||
replace: true,
|
||||
cols: 6,
|
||||
speed: 1.9,
|
||||
},
|
||||
{
|
||||
id: "pendulum-sweep-replace",
|
||||
name: "Pendulum Sweep Replace",
|
||||
note: "Wave pass swaps letters as it glides across the title",
|
||||
kind: "pendulum" as const,
|
||||
color: "#FFE865",
|
||||
replace: true,
|
||||
cols: 6,
|
||||
speed: 2.1,
|
||||
},
|
||||
{
|
||||
id: "compress-flash-replace",
|
||||
name: "Compress Flash Replace",
|
||||
note: "Sharper compressed pass that temporarily rewrites the title",
|
||||
kind: "compress" as const,
|
||||
color: "#FFE865",
|
||||
replace: true,
|
||||
cols: 6,
|
||||
speed: 2.3,
|
||||
},
|
||||
{
|
||||
id: "sort-spark-replace",
|
||||
name: "Sort Spark Replace",
|
||||
note: "Brighter noisy pass that replaces letters and settles them back",
|
||||
kind: "sort" as const,
|
||||
color: "#FFE865",
|
||||
replace: true,
|
||||
cols: 6,
|
||||
speed: 2.4,
|
||||
},
|
||||
{
|
||||
id: "pendulum-glow-replace",
|
||||
name: "Pendulum Glow Replace",
|
||||
note: "Softer pendulum pass that swaps title letters in place",
|
||||
kind: "pendulum" as const,
|
||||
color: "#FFE865",
|
||||
replace: true,
|
||||
cols: 6,
|
||||
speed: 1.4,
|
||||
},
|
||||
{
|
||||
id: "compress-sweep-replace",
|
||||
name: "Compress Sweep Replace",
|
||||
note: "Compression pass that rewrites the title as it crosses",
|
||||
kind: "compress" as const,
|
||||
color: "#FFE865",
|
||||
replace: true,
|
||||
cols: 6,
|
||||
speed: 1.9,
|
||||
},
|
||||
{
|
||||
id: "sort-sweep-replace",
|
||||
name: "Sort Sweep Replace",
|
||||
note: "Settling sort pass that temporarily replaces each character",
|
||||
kind: "sort" as const,
|
||||
color: "#FFE865",
|
||||
replace: true,
|
||||
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]
|
||||
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
|
||||
kind: BrailleKind
|
||||
color: string
|
||||
fx?: string
|
||||
cols: number
|
||||
anim: number
|
||||
move: number
|
||||
}) => {
|
||||
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 drop-shadow-[0_0_10px_currentColor] select-none ${props.fx ?? ""}`}
|
||||
style={{ color: props.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
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 slide = 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(slide)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="min-w-0 flex-1 overflow-hidden py-0.5">
|
||||
<div class="truncate whitespace-nowrap font-mono text-[13px] font-semibold text-text-strong">
|
||||
<For each={chars()}>
|
||||
{(char, idx) => {
|
||||
const offset = () => idx() - state.pos
|
||||
const active = () => offset() >= 0 && offset() < props.cols
|
||||
const next = () => (active() ? (frames()[state.idx][offset()] ?? char) : char)
|
||||
return (
|
||||
<span classList={{ "text-[12px]": active() }} style={{ color: active() ? props.color : undefined }}>
|
||||
{next()}
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
reviewPanel: () => JSX.Element
|
||||
@@ -39,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)")
|
||||
@@ -47,6 +696,7 @@ export function SessionSidePanel(props: {
|
||||
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const fixed = () => fixedTabs
|
||||
const panelWidth = createMemo(() => {
|
||||
if (!open()) return "0px"
|
||||
if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
|
||||
@@ -70,6 +720,10 @@ export function SessionSidePanel(props: {
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.noChanges"
|
||||
})
|
||||
const title = createMemo(() => info()?.title?.trim() || language.t("command.session.new"))
|
||||
const [spinner, setSpinner] = createStore({
|
||||
order: defs.map((row) => row.id) as DefId[],
|
||||
})
|
||||
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
@@ -137,12 +791,329 @@ export function SessionSidePanel(props: {
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
fixed,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
const activeTab = tabState.activeTab
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
|
||||
const spinnerPanel = () => {
|
||||
const rows = createMemo(() => spinner.order.map((id) => defsById.get(id)).filter((row) => row !== undefined))
|
||||
const preview = (name: string) => `${title()} with ${name}`
|
||||
const move = (from: DefId, to: DefId) => {
|
||||
if (from === to) return
|
||||
const order = [...spinner.order]
|
||||
const fromIdx = order.indexOf(from)
|
||||
const toIdx = order.indexOf(to)
|
||||
if (fromIdx === -1 || toIdx === -1) return
|
||||
const [row] = order.splice(fromIdx, 1)
|
||||
order.splice(toIdx, 0, row)
|
||||
setSpinner("order", order)
|
||||
}
|
||||
const shift = (id: DefId, delta: -1 | 1) => {
|
||||
const idx = spinner.order.indexOf(id)
|
||||
const next = idx + delta
|
||||
if (idx === -1 || next < 0 || next >= spinner.order.length) return
|
||||
const order = [...spinner.order]
|
||||
const [row] = order.splice(idx, 1)
|
||||
order.splice(next, 0, row)
|
||||
setSpinner("order", order)
|
||||
}
|
||||
const order = (row: Def, idx: number) => (
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
variant="ghost"
|
||||
class="h-6 w-6"
|
||||
onPointerDown={(event: PointerEvent) => event.stopPropagation()}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
shift(row.id, -1)
|
||||
}}
|
||||
disabled={idx === 0}
|
||||
aria-label={`Move ${row.name} up`}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-down-to-line"
|
||||
variant="ghost"
|
||||
class="h-6 w-6"
|
||||
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`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
const controls = (row: Def, idx: number) => (
|
||||
<DropdownMenu gutter={6} placement="bottom-end" modal={false}>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="sliders"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={`${row.name} settings`}
|
||||
onPointerDown={(event: PointerEvent) => event.stopPropagation()}
|
||||
onClick={(event: MouseEvent) => event.stopPropagation()}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="w-52 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
{"kind" 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">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>
|
||||
)}
|
||||
{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={spinnerLab.tune[row.id].color}
|
||||
class="ml-auto h-5 w-7 cursor-pointer rounded border-none bg-transparent p-0"
|
||||
onInput={(event) => spinnerLab.setTune(row.id, "color", event.currentTarget.value)}
|
||||
aria-label={`${row.name} color`}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="flex h-full min-h-0 flex-col bg-background-base">
|
||||
<div class="border-b border-border-weaker-base px-4 py-3">
|
||||
<div class="text-13-medium text-text-strong">Spinner concepts</div>
|
||||
<div class="mt-1 text-12-regular text-text-weak">
|
||||
Current session title with adjustable loading treatments.
|
||||
</div>
|
||||
<div class="mt-1 text-11-regular text-text-weaker">
|
||||
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 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())}
|
||||
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={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={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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div class="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<Braille
|
||||
kind={row.kind}
|
||||
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: spinnerLab.tune[row.id].color }}
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-14-medium text-text-strong">{preview(row.name)}</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</SpinnerConcept>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
|
||||
const setFileTreeTabValue = (value: string) => {
|
||||
@@ -168,11 +1139,13 @@ export function SessionSidePanel(props: {
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (!draggable || !droppable) return
|
||||
const from = draggable.id.toString()
|
||||
const to = droppable.id.toString()
|
||||
|
||||
const currentTabs = tabs().all()
|
||||
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
|
||||
const toIndex = getTabReorderIndex(currentTabs, from, to)
|
||||
if (toIndex === undefined) return
|
||||
tabs().move(draggable.id.toString(), toIndex)
|
||||
tabs().move(from, toIndex)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
@@ -225,16 +1198,16 @@ export function SessionSidePanel(props: {
|
||||
}}
|
||||
>
|
||||
<div class="size-full min-w-0 h-full bg-background-base">
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={activeTab()} onChange={openTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs value={activeTab()} onChange={openTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
const stop = createFileTabListSync({ el, contextOpen })
|
||||
@@ -251,6 +1224,9 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Tabs.Trigger value="spinners">
|
||||
<div>Spinners</div>
|
||||
</Tabs.Trigger>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
@@ -301,54 +1277,58 @@ export function SessionSidePanel(props: {
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
return (
|
||||
<div data-component="tabs-drag-preview">
|
||||
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
<Show when={reviewTab()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "empty"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
<Tabs.Content value="spinners" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "spinners"}>{spinnerPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "empty"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "context"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab />
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "context"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab />
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={activeFileTab()} keyed>
|
||||
{(tab) => <FileTabContent tab={tab} />}
|
||||
</Show>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
return (
|
||||
<div data-component="tabs-drag-preview">
|
||||
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<Show when={activeFileTab()} keyed>
|
||||
{(tab) => <FileTabContent tab={tab} />}
|
||||
</Show>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user