tweak(ui): shimmering titles and animated counts

This commit is contained in:
Kit Langton
2026-02-27 21:20:41 -05:00
parent 324230806e
commit 90345c57e1
9 changed files with 357 additions and 269 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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