mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 14:55:19 +00:00
tweak(ui): shimmering titles and animated counts
This commit is contained in:
1
bun.lock
1
bun.lock
@@ -459,7 +459,6 @@
|
||||
"@storybook/addon-links": "^10.2.13",
|
||||
"@storybook/addon-onboarding": "^10.2.13",
|
||||
"@storybook/addon-vitest": "^10.2.13",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "18.0.25",
|
||||
|
||||
@@ -550,228 +550,215 @@ export function MessageTimeline(props: {
|
||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||
}}
|
||||
>
|
||||
<div ref={props.setContentRef} class="min-w-0 w-full">
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<Show when={parentID()}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={sessionID()}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
"mt-0.5": props.centered,
|
||||
"mt-0": !props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.turnStart > 0 || props.historyMore}>
|
||||
<div class="w-full flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="text-12-medium opacity-50"
|
||||
disabled={props.historyLoading}
|
||||
onClick={props.onLoadEarlier}
|
||||
>
|
||||
{props.historyLoading
|
||||
? language.t("session.messages.loadingEarlier")
|
||||
: language.t("session.messages.loadEarlier")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={rendered()}>
|
||||
{(messageID) => {
|
||||
const active = createMemo(() => activeMessageID() === messageID)
|
||||
const queued = createMemo(() => {
|
||||
if (active()) return false
|
||||
const activeID = activeMessageID()
|
||||
if (activeID) return messageID > activeID
|
||||
return false
|
||||
})
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
||||
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
||||
})
|
||||
const commentCount = createMemo(() => comments().length)
|
||||
return (
|
||||
<div
|
||||
id={props.anchor(messageID)}
|
||||
data-message-id={messageID}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, messageID)
|
||||
onCleanup(() => props.onUnregisterMessage(messageID))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<Show when={parentID()}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<Show when={commentCount() > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(commentAccessor: () => MessageComment) => {
|
||||
const comment = createMemo(() => commentAccessor())
|
||||
return (
|
||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
<FileIcon
|
||||
node={{ path: comment().path, type: "file" }}
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{getFilename(comment().path)}</span>
|
||||
<Show when={comment().selection}>
|
||||
{(selection) => (
|
||||
<span class="shrink-0 text-text-weak">
|
||||
{selection().startLine === selection().endLine
|
||||
? `:${selection().startLine}`
|
||||
: `:${selection().startLine}-${selection().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{comment().comment}
|
||||
</div>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={sessionID()}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={props.setContentRef}
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
"mt-0.5": props.centered,
|
||||
"mt-0": !props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.turnStart > 0 || props.historyMore}>
|
||||
<div class="w-full flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="text-12-medium opacity-50"
|
||||
disabled={props.historyLoading}
|
||||
onClick={props.onLoadEarlier}
|
||||
>
|
||||
{props.historyLoading
|
||||
? language.t("session.messages.loadingEarlier")
|
||||
: language.t("session.messages.loadEarlier")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={rendered()}>
|
||||
{(messageID) => {
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []))
|
||||
return (
|
||||
<div
|
||||
id={props.anchor(messageID)}
|
||||
data-message-id={messageID}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, messageID)
|
||||
onCleanup(() => props.onUnregisterMessage(messageID))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={commentCount() > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(commentAccessor: () => MessageComment) => {
|
||||
const comment = createMemo(() => commentAccessor())
|
||||
return (
|
||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
<FileIcon
|
||||
node={{ path: comment().path, type: "file" }}
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{getFilename(comment().path)}</span>
|
||||
<Show when={comment().selection}>
|
||||
{(selection) => (
|
||||
<span class="shrink-0 text-text-weak">
|
||||
{selection().startLine === selection().endLine
|
||||
? `:${selection().startLine}`
|
||||
: `:${selection().startLine}-${selection().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{comment().comment}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={messageID}
|
||||
active={active()}
|
||||
queued={queued()}
|
||||
status={active() ? sessionStatus() : undefined}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-5",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={messageID}
|
||||
lastUserMessageID={props.lastUserMessageID}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-5",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"
|
||||
|
||||
const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
|
||||
const DURATION = 600
|
||||
const DURATION = 700
|
||||
|
||||
function normalize(value: number) {
|
||||
return ((value % 10) + 10) % 10
|
||||
|
||||
@@ -132,7 +132,9 @@ export function BasicTool(props: BasicToolProps) {
|
||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
||||
}}
|
||||
>
|
||||
<TextShimmer text={trigger().title} active={pending()} />
|
||||
<Show when={pending()} fallback={trigger().title}>
|
||||
<TextShimmer text={trigger().title} active />
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<Show when={trigger().subtitle}>
|
||||
|
||||
@@ -51,40 +51,6 @@ import { IconButton } from "./icon-button"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { AnimatedCountList } from "./tool-count-summary"
|
||||
import { ToolStatusTitle } from "./tool-status-title"
|
||||
import { animate } from "motion"
|
||||
|
||||
function ShellSubmessage(props: { text: string; animate?: boolean }) {
|
||||
let widthRef: HTMLSpanElement | undefined
|
||||
let valueRef: HTMLSpanElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
if (!props.animate) return
|
||||
requestAnimationFrame(() => {
|
||||
if (widthRef) {
|
||||
animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })
|
||||
}
|
||||
if (valueRef) {
|
||||
animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<span data-component="shell-submessage">
|
||||
<span ref={widthRef} data-slot="shell-submessage-width" style={{ width: props.animate ? "0px" : undefined }}>
|
||||
<span data-slot="basic-tool-tool-subtitle">
|
||||
<span
|
||||
ref={valueRef}
|
||||
data-slot="shell-submessage-value"
|
||||
style={props.animate ? { opacity: 0, filter: "blur(2px)" } : undefined}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface Diagnostic {
|
||||
range: {
|
||||
@@ -776,7 +742,9 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={trigger().title} active={running()} />
|
||||
<Show when={running} fallback={trigger.title}>
|
||||
<TextShimmer text={trigger.title} active />
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={!running() && trigger().subtitle}>
|
||||
<span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
|
||||
|
||||
@@ -10,14 +10,16 @@ Use for pending states inside buttons or list rows.
|
||||
|
||||
### API
|
||||
- Required: \`text\` string.
|
||||
- Optional: \`as\`, \`active\`, \`offset\`, \`class\`.
|
||||
- Optional: \`as\`, \`active\`, \`stepMs\`, \`durationMs\`, \`swapMs\`, \`offset\`.
|
||||
- Sweep controls: \`spread\` (band width), \`size\` (travel span), \`angle\`.
|
||||
- Color controls: \`base\`, \`peak\`.
|
||||
|
||||
### Variants and states
|
||||
- Active/inactive state via \`active\`.
|
||||
|
||||
### Behavior
|
||||
- Uses a moving gradient sweep clipped to text.
|
||||
- \`offset\` lets multiple shimmers run out-of-phase.
|
||||
- \`offset\` and \`stepMs\` let multiple shimmers run out-of-phase.
|
||||
|
||||
### Accessibility
|
||||
- Uses \`aria-label\` with the full text.
|
||||
@@ -31,6 +33,12 @@ const defaults = {
|
||||
text: "Loading...",
|
||||
active: true,
|
||||
class: "text-14-medium text-text-strong",
|
||||
durationMs: 1200,
|
||||
stepMs: 45,
|
||||
swapMs: 220,
|
||||
spread: 5.2,
|
||||
size: 360,
|
||||
angle: 90,
|
||||
offset: 0,
|
||||
} as const
|
||||
|
||||
@@ -46,7 +54,15 @@ export default {
|
||||
text: { control: "text" },
|
||||
class: { control: "text" },
|
||||
active: { control: "boolean" },
|
||||
durationMs: { control: { type: "range", min: 400, max: 4000, step: 50 } },
|
||||
stepMs: { control: { type: "range", min: 0, max: 200, step: 5 } },
|
||||
swapMs: { control: { type: "range", min: 0, max: 800, step: 10 } },
|
||||
spread: { control: { type: "range", min: 0.3, max: 6, step: 0.1 } },
|
||||
size: { control: { type: "range", min: 120, max: 400, step: 5 } },
|
||||
angle: { control: { type: "range", min: 0, max: 180, step: 1 } },
|
||||
offset: { control: { type: "range", min: 0, max: 80, step: 1 } },
|
||||
base: { control: "text" },
|
||||
peak: { control: "text" },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
@@ -90,3 +106,21 @@ export const Inactive = {
|
||||
active: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomTiming = {
|
||||
args: {
|
||||
text: "Custom timing",
|
||||
stepMs: 80,
|
||||
durationMs: 1800,
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomSweep = {
|
||||
args: {
|
||||
text: "Custom sweep",
|
||||
spread: 2.8,
|
||||
size: 280,
|
||||
angle: 96,
|
||||
durationMs: 1600,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,11 +7,22 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
|
||||
as?: T
|
||||
active?: boolean
|
||||
offset?: number
|
||||
stepMs?: number
|
||||
durationMs?: number
|
||||
swapMs?: number
|
||||
spread?: number
|
||||
size?: number
|
||||
angle?: number
|
||||
base?: string
|
||||
peak?: string
|
||||
}) => {
|
||||
const active = createMemo(() => props.active ?? true)
|
||||
const offset = createMemo(() => props.offset ?? 0)
|
||||
const swap = createMemo(() => props.swapMs ?? 220)
|
||||
const spread = createMemo(() => props.spread ?? 5.2)
|
||||
const size = createMemo(() => props.size ?? 360)
|
||||
const angle = createMemo(() => props.angle ?? 90)
|
||||
const [run, setRun] = createSignal(active())
|
||||
const swap = 220
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
createEffect(() => {
|
||||
@@ -28,7 +39,7 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined
|
||||
setRun(false)
|
||||
}, swap)
|
||||
}, swap())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -44,8 +55,15 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
|
||||
class={props.class}
|
||||
aria-label={props.text}
|
||||
style={{
|
||||
"--text-shimmer-swap": `${swap}ms`,
|
||||
"--text-shimmer-step": `${props.stepMs ?? 45}ms`,
|
||||
"--text-shimmer-duration": `${props.durationMs ?? 1200}ms`,
|
||||
"--text-shimmer-swap": `${swap()}ms`,
|
||||
"--text-shimmer-index": `${offset()}`,
|
||||
"--text-shimmer-spread": `${spread()}ch`,
|
||||
"--text-shimmer-size": `${size()}%`,
|
||||
"--text-shimmer-angle": `${angle()}deg`,
|
||||
"--text-shimmer-base-color": props.base ?? "var(--text-weak)",
|
||||
"--text-shimmer-peak-color": props.peak ?? "var(--text-strong)",
|
||||
}}
|
||||
>
|
||||
<span data-slot="text-shimmer-char">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { createSignal, onCleanup, For } from "solid-js"
|
||||
import { AnimatedCountList, type CountItem } from "./tool-count-summary"
|
||||
import { ToolStatusTitle } from "./tool-status-title"
|
||||
|
||||
@@ -21,14 +21,75 @@ as it appears in the context tool group on the session page.`,
|
||||
},
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
active: "Exploring",
|
||||
done: "Explored",
|
||||
read: { one: "{{count}} read", other: "{{count}} reads" },
|
||||
search: { one: "{{count}} search", other: "{{count}} searches" },
|
||||
list: { one: "{{count}} list", other: "{{count}} lists" },
|
||||
const LOCALES = {
|
||||
en: {
|
||||
label: "English",
|
||||
active: "Exploring",
|
||||
done: "Explored",
|
||||
read: { one: "{{count}} read", other: "{{count}} reads" },
|
||||
search: { one: "{{count}} search", other: "{{count}} searches" },
|
||||
list: { one: "{{count}} list", other: "{{count}} lists" },
|
||||
},
|
||||
fr: {
|
||||
label: "Français",
|
||||
active: "Exploration",
|
||||
done: "Exploré",
|
||||
read: { one: "{{count}} lecture", other: "{{count}} lectures" },
|
||||
search: { one: "{{count}} recherche", other: "{{count}} recherches" },
|
||||
list: { one: "{{count}} liste", other: "{{count}} listes" },
|
||||
},
|
||||
ja: {
|
||||
label: "日本語",
|
||||
active: "探索中",
|
||||
done: "探索済み",
|
||||
read: { one: "{{count}} 件の読み取り", other: "{{count}} 件の読み取り" },
|
||||
search: { one: "{{count}} 件の検索", other: "{{count}} 件の検索" },
|
||||
list: { one: "{{count}} 件のリスト", other: "{{count}} 件のリスト" },
|
||||
},
|
||||
ko: {
|
||||
label: "한국어",
|
||||
active: "탐색 중",
|
||||
done: "탐색됨",
|
||||
read: { one: "{{count}}개 읽음", other: "{{count}}개 읽음" },
|
||||
search: { one: "{{count}}개 검색", other: "{{count}}개 검색" },
|
||||
list: { one: "{{count}}개 목록", other: "{{count}}개 목록" },
|
||||
},
|
||||
de: {
|
||||
label: "Deutsch",
|
||||
active: "Erkunden",
|
||||
done: "Erkundet",
|
||||
read: { one: "{{count}} Lesevorgang", other: "{{count}} Lesevorgänge" },
|
||||
search: { one: "{{count}} Suche", other: "{{count}} Suchen" },
|
||||
list: { one: "{{count}} Liste", other: "{{count}} Listen" },
|
||||
},
|
||||
es: {
|
||||
label: "Español",
|
||||
active: "Explorando",
|
||||
done: "Explorado",
|
||||
read: { one: "{{count}} lectura", other: "{{count}} lecturas" },
|
||||
search: { one: "{{count}} búsqueda", other: "{{count}} búsquedas" },
|
||||
list: { one: "{{count}} lista", other: "{{count}} listas" },
|
||||
},
|
||||
th: {
|
||||
label: "ไทย",
|
||||
active: "กำลังสำรวจ",
|
||||
done: "สำรวจแล้ว",
|
||||
read: { one: "อ่าน {{count}} รายการ", other: "อ่าน {{count}} รายการ" },
|
||||
search: { one: "ค้นหา {{count}} รายการ", other: "ค้นหา {{count}} รายการ" },
|
||||
list: { one: "รายการ {{count}} รายการ", other: "รายการ {{count}} รายการ" },
|
||||
},
|
||||
ar: {
|
||||
label: "العربية",
|
||||
active: "استكشاف",
|
||||
done: "تم الاستكشاف",
|
||||
read: { one: "{{count}} قراءة", other: "{{count}} قراءات" },
|
||||
search: { one: "{{count}} بحث", other: "{{count}} عمليات بحث" },
|
||||
list: { one: "{{count}} قائمة", other: "{{count}} قوائم" },
|
||||
},
|
||||
} as const
|
||||
|
||||
type LocaleKey = keyof typeof LOCALES
|
||||
|
||||
function rand(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
@@ -61,8 +122,11 @@ export const Playground = {
|
||||
const [searches, setSearches] = createSignal(0)
|
||||
const [lists, setLists] = createSignal(0)
|
||||
const [active, setActive] = createSignal(false)
|
||||
const [locale, setLocale] = createSignal<LocaleKey>("en")
|
||||
const [reducedMotion, setReducedMotion] = createSignal(false)
|
||||
|
||||
const l = () => LOCALES[locale()]
|
||||
|
||||
let timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
const clearAll = () => {
|
||||
@@ -110,9 +174,9 @@ export const Playground = {
|
||||
}
|
||||
|
||||
const items = (): CountItem[] => [
|
||||
{ key: "read", count: reads(), one: TEXT.read.one, other: TEXT.read.other },
|
||||
{ key: "search", count: searches(), one: TEXT.search.one, other: TEXT.search.other },
|
||||
{ key: "list", count: lists(), one: TEXT.list.one, other: TEXT.list.other },
|
||||
{ key: "read", count: reads(), one: l().read.one, other: l().read.other },
|
||||
{ key: "search", count: searches(), one: l().search.one, other: l().search.other },
|
||||
{ key: "list", count: lists(), one: l().list.one, other: l().list.other },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -141,7 +205,7 @@ export const Playground = {
|
||||
}}
|
||||
>
|
||||
<span style={{ "flex-shrink": "0" }}>
|
||||
<ToolStatusTitle active={active()} activeText={TEXT.active} doneText={TEXT.done} split={false} />
|
||||
<ToolStatusTitle active={active()} activeText={l().active} doneText={l().done} split={false} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
@@ -157,6 +221,17 @@ export const Playground = {
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Language picker */}
|
||||
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
|
||||
<For each={Object.keys(LOCALES) as LocaleKey[]}>
|
||||
{(key) => (
|
||||
<button onClick={() => setLocale(key)} style={smallBtn(locale() === key)}>
|
||||
{LOCALES[key].label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={() => (active() ? stopSim() : startSim())} style={btn(active())}>
|
||||
{active() ? "Stop" : "Simulate"}
|
||||
@@ -188,8 +263,8 @@ export const Playground = {
|
||||
"font-family": "monospace",
|
||||
}}
|
||||
>
|
||||
motion: {reducedMotion() ? "reduced" : "normal"} · active: {active() ? "true" : "false"} · reads: {reads()} ·
|
||||
searches: {searches()} · lists: {lists()}
|
||||
{locale()} · motion: {reducedMotion() ? "reduced" : "normal"} · active: {active() ? "true" : "false"} · reads:{" "}
|
||||
{reads()} · searches: {searches()} · lists: {lists()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -72,7 +72,12 @@ export function ToolStatusTitle(props: {
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(on([() => props.active, activeTail, doneTail, suffix], () => schedule()))
|
||||
createEffect(
|
||||
on(
|
||||
[() => props.active, activeTail, doneTail, suffix],
|
||||
() => schedule(),
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
measure()
|
||||
|
||||
Reference in New Issue
Block a user