diff --git a/CHANGELOG.md b/CHANGELOG.md index 026c8291..77a0d8cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,13 @@ contract — modules that are not exported are internal and may change in any re `ThemeProvider` writes `.light` for resolved-light mode, so hosts on the provider need no change. +### Added + +- **`ui/shimmer-text` and `ui/thinking-label`** — a token-pure shimmer sweep + over text, and a rotating-verb status label built on it. Verb rotation is + width-stable (stacked grid, crossfade) and stops under reduced motion via + `usePrefersReducedMotion`. + ## [0.1.0] - 2026-09-17 Nothing has been published before this, so the list below is what the surface consists of diff --git a/package.json b/package.json index 362c44c2..1fadfd25 100644 --- a/package.json +++ b/package.json @@ -443,6 +443,10 @@ "types": "./dist/ui/settings-panel.d.ts", "default": "./dist/ui/settings-panel.js" }, + "./ui/shimmer-text": { + "types": "./dist/ui/shimmer-text.d.ts", + "default": "./dist/ui/shimmer-text.js" + }, "./ui/sidebar": { "types": "./dist/ui/sidebar.d.ts", "default": "./dist/ui/sidebar.js" @@ -507,6 +511,10 @@ "types": "./dist/ui/thinking-indicator.d.ts", "default": "./dist/ui/thinking-indicator.js" }, + "./ui/thinking-label": { + "types": "./dist/ui/thinking-label.d.ts", + "default": "./dist/ui/thinking-label.js" + }, "./ui/thinking-mark": { "types": "./dist/ui/thinking-mark.d.ts", "default": "./dist/ui/thinking-mark.js" diff --git a/src/index.ts b/src/index.ts index 0801b2fa..689a11f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,6 +96,7 @@ export * from "./ui/section.js"; export * from "./ui/select.js"; export * from "./ui/selection-checkbox.js"; export * from "./ui/settings-panel.js"; +export * from "./ui/shimmer-text.js"; export * from "./ui/sidebar.js"; export * from "./ui/sidebar-item-row.js"; export * from "./ui/sidebar-panel.js"; @@ -112,6 +113,7 @@ export * from "./ui/textarea.js"; export * from "./ui/theme-provider.js"; export * from "./ui/theme-toggle.js"; export * from "./ui/thinking-indicator.js"; +export * from "./ui/thinking-label.js"; export * from "./ui/thinking-mark.js"; export * from "./ui/time-series-chart.js"; export * from "./ui/toast.js"; diff --git a/src/theme.css b/src/theme.css index 3474d5ff..1c628752 100644 --- a/src/theme.css +++ b/src/theme.css @@ -191,6 +191,7 @@ --secondary-foreground: #e4d5bc; --muted: #1a1718; --muted-foreground: #a99c86; + --thinking-base: #aaa39a; --accent: #2d455c; --accent-foreground: #e4d5bc; --success: #425a3d; @@ -535,6 +536,18 @@ } } +/* ShimmerText's sweep: a `background-clip: text` gradient sliding across + its own background-size, so the brighter band travels through the words. + Used by ui/shimmer-text (and, through it, ui/thinking-label). */ +@keyframes corbits-shimmer-sweep { + 0% { + background-position: 150% 0; + } + 100% { + background-position: -50% 0; + } +} + /* Toast motion: a pure 160ms fade, centered above the bottom edge — no slide, no lift. Sonner enters and exits by transitioning translateY, so the transform is pinned (!important, since sonner injects its stylesheet diff --git a/src/ui/shimmer-text.test.tsx b/src/ui/shimmer-text.test.tsx new file mode 100644 index 00000000..72ce1f79 --- /dev/null +++ b/src/ui/shimmer-text.test.tsx @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ShimmerText } from "./shimmer-text.js"; + +describe("ShimmerText", () => { + test("reduced-motion and forced-colors fall back to solid muted text without clip", () => { + const html = renderToStaticMarkup(createElement(ShimmerText, { children: "Thinking" })); + expect(html).toContain("motion-reduce:text-muted-foreground"); + expect(html).toContain("motion-reduce:bg-none"); + expect(html).toContain("motion-reduce:bg-clip-border"); + expect(html).toContain("forced-colors:text-muted-foreground"); + expect(html).toContain("forced-colors:bg-none"); + expect(html).toContain("forced-colors:bg-clip-border"); + expect(html).toContain("text-transparent"); + }); +}); diff --git a/src/ui/shimmer-text.tsx b/src/ui/shimmer-text.tsx new file mode 100644 index 00000000..4170af7a --- /dev/null +++ b/src/ui/shimmer-text.tsx @@ -0,0 +1,39 @@ +import { cn } from "../lib/utils.js"; + +export type ShimmerTextProps = { + readonly children: string; + /** Omit when an ancestor already carries the live region. */ + readonly role?: "status"; + readonly className?: string; +}; + +/** + * A shimmer sweep over inline text: a brighter band slides through the + * words to signal "still going" without the words themselves changing. The + * sweep is a `background-clip: text` gradient animating its position, built + * from `--muted-foreground` and `--foreground` only — no new hex, so the + * contrast gate never sees it as a text/background pair. + * + * The theme's global reduced-motion rule collapses animation duration, but + * that would still leave `text-transparent` + `background-clip: text` with + * nothing to paint. Under `prefers-reduced-motion` and `forced-colors` this + * falls back to solid `text-muted-foreground` with no clip. + */ +export function ShimmerText({ children, role, className }: ShimmerTextProps) { + return ( + + {children} + + ); +} diff --git a/src/ui/thinking-label.test.tsx b/src/ui/thinking-label.test.tsx new file mode 100644 index 00000000..fa6e87c9 --- /dev/null +++ b/src/ui/thinking-label.test.tsx @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ThinkingLabel } from "./thinking-label.js"; + +type Restore = () => void; +type Listener = (event: { matches: boolean }) => void; + +function stubMatchMedia(initialReducedMotion: boolean): { restore: Restore; set: (value: boolean) => void } { + let matches = initialReducedMotion; + const listeners = new Set(); + const original = window.matchMedia; + window.matchMedia = ((query: string) => + ({ + get matches() { + return matches && query.includes("prefers-reduced-motion"); + }, + media: query, + addEventListener: (_type: string, listener: Listener) => listeners.add(listener), + removeEventListener: (_type: string, listener: Listener) => listeners.delete(listener), + }) as unknown as MediaQueryList) as typeof window.matchMedia; + return { + set: (value: boolean) => { + matches = value; + for (const listener of listeners) listener({ matches: value }); + }, + restore: () => { + window.matchMedia = original; + }, + }; +} + +function stubInterval(): { + readonly calls: Array<{ fn: () => void; ms: number | undefined }>; + readonly clears: number; + readonly restore: Restore; +} { + const originalSet = globalThis.setInterval; + const originalClear = globalThis.clearInterval; + const calls: Array<{ fn: () => void; ms: number | undefined }> = []; + const state = { clears: 0 }; + globalThis.setInterval = ((fn: TimerHandler, ms?: number) => { + calls.push({ fn: fn as () => void, ms }); + return 1 as unknown as ReturnType; + }) as unknown as typeof setInterval; + globalThis.clearInterval = (() => { + state.clears += 1; + }) as unknown as typeof clearInterval; + return { + calls, + get clears() { + return state.clears; + }, + restore: () => { + globalThis.setInterval = originalSet; + globalThis.clearInterval = originalClear; + }, + }; +} + +function mount(props: { verbs: readonly string[]; intervalMs?: number }) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(createElement(ThinkingLabel, props)); + }); + return { + container, + // Every verb stays mounted in the stacked grid — the visible one is the + // cell carrying opacity-100. + visible: () => container.querySelector('[aria-hidden] .opacity-100')?.textContent ?? "", + text: () => container.textContent ?? "", + status: () => container.querySelector('[role="status"]'), + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("ThinkingLabel", () => { + const restores: Restore[] = []; + afterEach(() => { + while (restores.length > 0) restores.pop()?.(); + }); + + test("empty verbs render nothing", () => { + const html = renderToStaticMarkup(createElement(ThinkingLabel, { verbs: [] })); + expect(html).toBe(""); + }); + + test("reduced motion does not setInterval", () => { + restores.push(stubMatchMedia(true).restore); + const interval = stubInterval(); + restores.push(interval.restore); + + const handle = mount({ verbs: ["Reading", "Thinking"] }); + expect(interval.calls).toHaveLength(0); + expect(handle.visible()).toBe("Reading"); + handle.unmount(); + }); + + test("turning reduced motion on live stops the interval", () => { + const media = stubMatchMedia(false); + restores.push(media.restore); + const interval = stubInterval(); + restores.push(interval.restore); + + const handle = mount({ verbs: ["Reading", "Thinking"] }); + expect(interval.calls).toHaveLength(1); + + act(() => { + interval.calls[0]?.fn(); + }); + expect(handle.visible()).toBe("Thinking"); + + act(() => media.set(true)); + expect(interval.clears).toBeGreaterThan(0); + expect(handle.visible()).toBe("Reading"); + + handle.unmount(); + }); + + test("rotation advances after intervalMs", () => { + restores.push(stubMatchMedia(false).restore); + const interval = stubInterval(); + restores.push(interval.restore); + + const handle = mount({ verbs: ["Reading", "Thinking", "Writing"], intervalMs: 50 }); + expect(interval.calls).toHaveLength(1); + expect(interval.calls[0]?.ms).toBe(50); + expect(handle.visible()).toBe("Reading"); + + act(() => { + interval.calls[0]?.fn(); + }); + expect(handle.visible()).toBe("Thinking"); + + act(() => { + interval.calls[0]?.fn(); + }); + expect(handle.visible()).toBe("Writing"); + + handle.unmount(); + }); + + test("status announces Working once and does not live-announce verb ticks", () => { + restores.push(stubMatchMedia(false).restore); + const interval = stubInterval(); + restores.push(interval.restore); + + const handle = mount({ verbs: ["Reading", "Thinking"], intervalMs: 50 }); + expect(handle.status()?.textContent).toBe("Working"); + expect(handle.status()?.getAttribute("aria-live")).toBeNull(); + expect(handle.container.querySelector("[aria-live]")).toBeNull(); + + act(() => { + interval.calls[0]?.fn(); + }); + expect(handle.status()?.textContent).toBe("Working"); + expect(handle.visible()).toBe("Thinking"); + + handle.unmount(); + }); +}); diff --git a/src/ui/thinking-label.tsx b/src/ui/thinking-label.tsx new file mode 100644 index 00000000..b6071819 --- /dev/null +++ b/src/ui/thinking-label.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; + +import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js"; +import { cn } from "../lib/utils.js"; +import { ShimmerText } from "./shimmer-text.js"; + +export type ThinkingLabelProps = { + /** What the agent is doing, in its own words — rotated in order. */ + readonly verbs: readonly string[]; + /** Rotation cadence. A heartbeat, not a ticker — keep it at ~1s or slower. */ + readonly intervalMs?: number; + /** What the status role announces, once. Defaults to "Working". */ + readonly statusLabel?: string; + readonly className?: string; +}; + +const DEFAULT_INTERVAL_MS = 3_000; + +/** + * The shimmering "working" word, rotating through caller-supplied verbs on + * an interval. Every verb renders stacked in one grid cell, so the element's + * width is the widest verb and a swap crossfades instead of reflowing + * siblings. Visual rotation is decorative: a single `role="status"` + * announces "Working" once, so a screen reader is not re-interrupted every + * few seconds. Reduced motion is live via `usePrefersReducedMotion` and + * stops the rotation rather than just stilling the shimmer. + */ +export function ThinkingLabel({ + verbs, + intervalMs = DEFAULT_INTERVAL_MS, + statusLabel = "Working", + className, +}: ThinkingLabelProps) { + const [at, setAt] = useState(0); + const reduceMotion = usePrefersReducedMotion(); + + useEffect(() => { + if (verbs.length < 2 || !reduceMotion) return; + setAt(0); + }, [verbs.length, reduceMotion]); + + useEffect(() => { + if (verbs.length < 2 || reduceMotion) return; + const timer = setInterval(() => setAt((current) => (current + 1) % verbs.length), intervalMs); + return () => clearInterval(timer); + }, [verbs.length, intervalMs, reduceMotion]); + + if (verbs.length === 0) return null; + + const active = at % verbs.length; + + return ( + + + {statusLabel} + + + {verbs.map((verb, index) => ( + + {verb} + + ))} + + + ); +} diff --git a/stories/primitives/shimmer-text.stories.tsx b/stories/primitives/shimmer-text.stories.tsx new file mode 100644 index 00000000..17997331 --- /dev/null +++ b/stories/primitives/shimmer-text.stories.tsx @@ -0,0 +1,15 @@ +import { ShimmerText } from "../../src/ui/shimmer-text.js"; + +export default { title: "Primitives / Shimmer text" }; + +export const Default = () => ( +
+ Reading the codebase… +
+); + +export const AsStatus = () => ( +
+ Searching… +
+); diff --git a/stories/primitives/thinking-label.stories.tsx b/stories/primitives/thinking-label.stories.tsx new file mode 100644 index 00000000..862a0fcf --- /dev/null +++ b/stories/primitives/thinking-label.stories.tsx @@ -0,0 +1,21 @@ +import { ThinkingLabel } from "../../src/ui/thinking-label.js"; + +export default { title: "Primitives / Thinking label" }; + +export const Default = () => ( +
+ +
+); + +export const FastRotation = () => ( +
+ +
+); + +export const SingleVerb = () => ( +
+ +
+);