Skip to content

Commit 6a939ee

Browse files
feat(chat): ShimmerText and ThinkingLabel with rotating verbs (CL-8481) (#43)
* feat(chat): ShimmerText and ThinkingLabel with rotating verbs (CL-8481) * test(chat): cover ThinkingLabel empty verbs, reduced-motion, and rotation (CL-8481) Empty verbs render nothing; prefers-reduced-motion never starts the interval; the verb advances when the interval callback fires after intervalMs. * fix(chat): readable reduced-motion shimmer; live motion hook (CL-8481) ShimmerText falls back to solid muted text under prefers-reduced-motion and forced-colors. ThinkingLabel uses usePrefersReducedMotion and announces Working once instead of aria-live on every verb tick. * chore: regenerate exports for shimmer-text and thinking-label * fix(chat): width-stable crossfading verb rotation; honest interval test (CL-8481) * feat(chat): overridable statusLabel on ThinkingLabel (CL-8481) * fix(theme): --thinking-base in the media-query dark block (CL-8481)
1 parent a31c83f commit 6a939ee

10 files changed

Lines changed: 363 additions & 0 deletions

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ contract — modules that are not exported are internal and may change in any re
5555
`ThemeProvider` writes `.light` for resolved-light mode, so hosts on the provider need
5656
no change.
5757

58+
### Added
59+
60+
- **`ui/shimmer-text` and `ui/thinking-label`** — a token-pure shimmer sweep
61+
over text, and a rotating-verb status label built on it. Verb rotation is
62+
width-stable (stacked grid, crossfade) and stops under reduced motion via
63+
`usePrefersReducedMotion`.
64+
5865
## [0.1.0] - 2026-09-17
5966

6067
Nothing has been published before this, so the list below is what the surface consists of

‎package.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,10 @@
443443
"types": "./dist/ui/settings-panel.d.ts",
444444
"default": "./dist/ui/settings-panel.js"
445445
},
446+
"./ui/shimmer-text": {
447+
"types": "./dist/ui/shimmer-text.d.ts",
448+
"default": "./dist/ui/shimmer-text.js"
449+
},
446450
"./ui/sidebar": {
447451
"types": "./dist/ui/sidebar.d.ts",
448452
"default": "./dist/ui/sidebar.js"
@@ -507,6 +511,10 @@
507511
"types": "./dist/ui/thinking-indicator.d.ts",
508512
"default": "./dist/ui/thinking-indicator.js"
509513
},
514+
"./ui/thinking-label": {
515+
"types": "./dist/ui/thinking-label.d.ts",
516+
"default": "./dist/ui/thinking-label.js"
517+
},
510518
"./ui/thinking-mark": {
511519
"types": "./dist/ui/thinking-mark.d.ts",
512520
"default": "./dist/ui/thinking-mark.js"

‎src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export * from "./ui/section.js";
9696
export * from "./ui/select.js";
9797
export * from "./ui/selection-checkbox.js";
9898
export * from "./ui/settings-panel.js";
99+
export * from "./ui/shimmer-text.js";
99100
export * from "./ui/sidebar.js";
100101
export * from "./ui/sidebar-item-row.js";
101102
export * from "./ui/sidebar-panel.js";
@@ -112,6 +113,7 @@ export * from "./ui/textarea.js";
112113
export * from "./ui/theme-provider.js";
113114
export * from "./ui/theme-toggle.js";
114115
export * from "./ui/thinking-indicator.js";
116+
export * from "./ui/thinking-label.js";
115117
export * from "./ui/thinking-mark.js";
116118
export * from "./ui/time-series-chart.js";
117119
export * from "./ui/toast.js";

‎src/theme.css‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@
191191
--secondary-foreground: #e4d5bc;
192192
--muted: #1a1718;
193193
--muted-foreground: #a99c86;
194+
--thinking-base: #aaa39a;
194195
--accent: #2d455c;
195196
--accent-foreground: #e4d5bc;
196197
--success: #425a3d;
@@ -535,6 +536,18 @@
535536
}
536537
}
537538

539+
/* ShimmerText's sweep: a `background-clip: text` gradient sliding across
540+
its own background-size, so the brighter band travels through the words.
541+
Used by ui/shimmer-text (and, through it, ui/thinking-label). */
542+
@keyframes corbits-shimmer-sweep {
543+
0% {
544+
background-position: 150% 0;
545+
}
546+
100% {
547+
background-position: -50% 0;
548+
}
549+
}
550+
538551
/* Toast motion: a pure 160ms fade, centered above the bottom edge — no
539552
slide, no lift. Sonner enters and exits by transitioning translateY, so
540553
the transform is pinned (!important, since sonner injects its stylesheet

‎src/ui/shimmer-text.test.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { createElement } from "react";
3+
import { renderToStaticMarkup } from "react-dom/server";
4+
5+
import { ShimmerText } from "./shimmer-text.js";
6+
7+
describe("ShimmerText", () => {
8+
test("reduced-motion and forced-colors fall back to solid muted text without clip", () => {
9+
const html = renderToStaticMarkup(createElement(ShimmerText, { children: "Thinking" }));
10+
expect(html).toContain("motion-reduce:text-muted-foreground");
11+
expect(html).toContain("motion-reduce:bg-none");
12+
expect(html).toContain("motion-reduce:bg-clip-border");
13+
expect(html).toContain("forced-colors:text-muted-foreground");
14+
expect(html).toContain("forced-colors:bg-none");
15+
expect(html).toContain("forced-colors:bg-clip-border");
16+
expect(html).toContain("text-transparent");
17+
});
18+
});

‎src/ui/shimmer-text.tsx‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { cn } from "../lib/utils.js";
2+
3+
export type ShimmerTextProps = {
4+
readonly children: string;
5+
/** Omit when an ancestor already carries the live region. */
6+
readonly role?: "status";
7+
readonly className?: string;
8+
};
9+
10+
/**
11+
* A shimmer sweep over inline text: a brighter band slides through the
12+
* words to signal "still going" without the words themselves changing. The
13+
* sweep is a `background-clip: text` gradient animating its position, built
14+
* from `--muted-foreground` and `--foreground` only — no new hex, so the
15+
* contrast gate never sees it as a text/background pair.
16+
*
17+
* The theme's global reduced-motion rule collapses animation duration, but
18+
* that would still leave `text-transparent` + `background-clip: text` with
19+
* nothing to paint. Under `prefers-reduced-motion` and `forced-colors` this
20+
* falls back to solid `text-muted-foreground` with no clip.
21+
*/
22+
export function ShimmerText({ children, role, className }: ShimmerTextProps) {
23+
return (
24+
<span
25+
role={role}
26+
className={cn(
27+
"bg-clip-text text-transparent",
28+
"[background-image:linear-gradient(90deg,var(--muted-foreground)_40%,var(--foreground)_50%,var(--muted-foreground)_60%)]",
29+
"[background-size:200%_100%]",
30+
"[animation:corbits-shimmer-sweep_1.8s_linear_infinite]",
31+
"motion-reduce:animate-none motion-reduce:bg-none motion-reduce:bg-clip-border motion-reduce:text-muted-foreground",
32+
"forced-colors:animate-none forced-colors:bg-none forced-colors:bg-clip-border forced-colors:text-muted-foreground",
33+
className,
34+
)}
35+
>
36+
{children}
37+
</span>
38+
);
39+
}

‎src/ui/thinking-label.test.tsx‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { act, createElement } from "react";
3+
import { createRoot } from "react-dom/client";
4+
import { renderToStaticMarkup } from "react-dom/server";
5+
6+
import { ThinkingLabel } from "./thinking-label.js";
7+
8+
type Restore = () => void;
9+
type Listener = (event: { matches: boolean }) => void;
10+
11+
function stubMatchMedia(initialReducedMotion: boolean): { restore: Restore; set: (value: boolean) => void } {
12+
let matches = initialReducedMotion;
13+
const listeners = new Set<Listener>();
14+
const original = window.matchMedia;
15+
window.matchMedia = ((query: string) =>
16+
({
17+
get matches() {
18+
return matches && query.includes("prefers-reduced-motion");
19+
},
20+
media: query,
21+
addEventListener: (_type: string, listener: Listener) => listeners.add(listener),
22+
removeEventListener: (_type: string, listener: Listener) => listeners.delete(listener),
23+
}) as unknown as MediaQueryList) as typeof window.matchMedia;
24+
return {
25+
set: (value: boolean) => {
26+
matches = value;
27+
for (const listener of listeners) listener({ matches: value });
28+
},
29+
restore: () => {
30+
window.matchMedia = original;
31+
},
32+
};
33+
}
34+
35+
function stubInterval(): {
36+
readonly calls: Array<{ fn: () => void; ms: number | undefined }>;
37+
readonly clears: number;
38+
readonly restore: Restore;
39+
} {
40+
const originalSet = globalThis.setInterval;
41+
const originalClear = globalThis.clearInterval;
42+
const calls: Array<{ fn: () => void; ms: number | undefined }> = [];
43+
const state = { clears: 0 };
44+
globalThis.setInterval = ((fn: TimerHandler, ms?: number) => {
45+
calls.push({ fn: fn as () => void, ms });
46+
return 1 as unknown as ReturnType<typeof setInterval>;
47+
}) as unknown as typeof setInterval;
48+
globalThis.clearInterval = (() => {
49+
state.clears += 1;
50+
}) as unknown as typeof clearInterval;
51+
return {
52+
calls,
53+
get clears() {
54+
return state.clears;
55+
},
56+
restore: () => {
57+
globalThis.setInterval = originalSet;
58+
globalThis.clearInterval = originalClear;
59+
},
60+
};
61+
}
62+
63+
function mount(props: { verbs: readonly string[]; intervalMs?: number }) {
64+
const container = document.createElement("div");
65+
document.body.appendChild(container);
66+
const root = createRoot(container);
67+
act(() => {
68+
root.render(createElement(ThinkingLabel, props));
69+
});
70+
return {
71+
container,
72+
// Every verb stays mounted in the stacked grid — the visible one is the
73+
// cell carrying opacity-100.
74+
visible: () => container.querySelector('[aria-hidden] .opacity-100')?.textContent ?? "",
75+
text: () => container.textContent ?? "",
76+
status: () => container.querySelector('[role="status"]'),
77+
unmount: () => {
78+
act(() => root.unmount());
79+
container.remove();
80+
},
81+
};
82+
}
83+
84+
describe("ThinkingLabel", () => {
85+
const restores: Restore[] = [];
86+
afterEach(() => {
87+
while (restores.length > 0) restores.pop()?.();
88+
});
89+
90+
test("empty verbs render nothing", () => {
91+
const html = renderToStaticMarkup(createElement(ThinkingLabel, { verbs: [] }));
92+
expect(html).toBe("");
93+
});
94+
95+
test("reduced motion does not setInterval", () => {
96+
restores.push(stubMatchMedia(true).restore);
97+
const interval = stubInterval();
98+
restores.push(interval.restore);
99+
100+
const handle = mount({ verbs: ["Reading", "Thinking"] });
101+
expect(interval.calls).toHaveLength(0);
102+
expect(handle.visible()).toBe("Reading");
103+
handle.unmount();
104+
});
105+
106+
test("turning reduced motion on live stops the interval", () => {
107+
const media = stubMatchMedia(false);
108+
restores.push(media.restore);
109+
const interval = stubInterval();
110+
restores.push(interval.restore);
111+
112+
const handle = mount({ verbs: ["Reading", "Thinking"] });
113+
expect(interval.calls).toHaveLength(1);
114+
115+
act(() => {
116+
interval.calls[0]?.fn();
117+
});
118+
expect(handle.visible()).toBe("Thinking");
119+
120+
act(() => media.set(true));
121+
expect(interval.clears).toBeGreaterThan(0);
122+
expect(handle.visible()).toBe("Reading");
123+
124+
handle.unmount();
125+
});
126+
127+
test("rotation advances after intervalMs", () => {
128+
restores.push(stubMatchMedia(false).restore);
129+
const interval = stubInterval();
130+
restores.push(interval.restore);
131+
132+
const handle = mount({ verbs: ["Reading", "Thinking", "Writing"], intervalMs: 50 });
133+
expect(interval.calls).toHaveLength(1);
134+
expect(interval.calls[0]?.ms).toBe(50);
135+
expect(handle.visible()).toBe("Reading");
136+
137+
act(() => {
138+
interval.calls[0]?.fn();
139+
});
140+
expect(handle.visible()).toBe("Thinking");
141+
142+
act(() => {
143+
interval.calls[0]?.fn();
144+
});
145+
expect(handle.visible()).toBe("Writing");
146+
147+
handle.unmount();
148+
});
149+
150+
test("status announces Working once and does not live-announce verb ticks", () => {
151+
restores.push(stubMatchMedia(false).restore);
152+
const interval = stubInterval();
153+
restores.push(interval.restore);
154+
155+
const handle = mount({ verbs: ["Reading", "Thinking"], intervalMs: 50 });
156+
expect(handle.status()?.textContent).toBe("Working");
157+
expect(handle.status()?.getAttribute("aria-live")).toBeNull();
158+
expect(handle.container.querySelector("[aria-live]")).toBeNull();
159+
160+
act(() => {
161+
interval.calls[0]?.fn();
162+
});
163+
expect(handle.status()?.textContent).toBe("Working");
164+
expect(handle.visible()).toBe("Thinking");
165+
166+
handle.unmount();
167+
});
168+
});

‎src/ui/thinking-label.tsx‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useEffect, useState } from "react";
2+
3+
import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js";
4+
import { cn } from "../lib/utils.js";
5+
import { ShimmerText } from "./shimmer-text.js";
6+
7+
export type ThinkingLabelProps = {
8+
/** What the agent is doing, in its own words — rotated in order. */
9+
readonly verbs: readonly string[];
10+
/** Rotation cadence. A heartbeat, not a ticker — keep it at ~1s or slower. */
11+
readonly intervalMs?: number;
12+
/** What the status role announces, once. Defaults to "Working". */
13+
readonly statusLabel?: string;
14+
readonly className?: string;
15+
};
16+
17+
const DEFAULT_INTERVAL_MS = 3_000;
18+
19+
/**
20+
* The shimmering "working" word, rotating through caller-supplied verbs on
21+
* an interval. Every verb renders stacked in one grid cell, so the element's
22+
* width is the widest verb and a swap crossfades instead of reflowing
23+
* siblings. Visual rotation is decorative: a single `role="status"`
24+
* announces "Working" once, so a screen reader is not re-interrupted every
25+
* few seconds. Reduced motion is live via `usePrefersReducedMotion` and
26+
* stops the rotation rather than just stilling the shimmer.
27+
*/
28+
export function ThinkingLabel({
29+
verbs,
30+
intervalMs = DEFAULT_INTERVAL_MS,
31+
statusLabel = "Working",
32+
className,
33+
}: ThinkingLabelProps) {
34+
const [at, setAt] = useState(0);
35+
const reduceMotion = usePrefersReducedMotion();
36+
37+
useEffect(() => {
38+
if (verbs.length < 2 || !reduceMotion) return;
39+
setAt(0);
40+
}, [verbs.length, reduceMotion]);
41+
42+
useEffect(() => {
43+
if (verbs.length < 2 || reduceMotion) return;
44+
const timer = setInterval(() => setAt((current) => (current + 1) % verbs.length), intervalMs);
45+
return () => clearInterval(timer);
46+
}, [verbs.length, intervalMs, reduceMotion]);
47+
48+
if (verbs.length === 0) return null;
49+
50+
const active = at % verbs.length;
51+
52+
return (
53+
<span className={cn("text-sm", className)}>
54+
<span role="status" className="sr-only">
55+
{statusLabel}
56+
</span>
57+
<span aria-hidden className="inline-grid">
58+
{verbs.map((verb, index) => (
59+
<ShimmerText
60+
key={index}
61+
className={cn(
62+
"col-start-1 row-start-1 transition-opacity duration-200",
63+
index === active ? "opacity-100" : "opacity-0",
64+
)}
65+
>
66+
{verb}
67+
</ShimmerText>
68+
))}
69+
</span>
70+
</span>
71+
);
72+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ShimmerText } from "../../src/ui/shimmer-text.js";
2+
3+
export default { title: "Primitives / Shimmer text" };
4+
5+
export const Default = () => (
6+
<div className="p-4 text-sm">
7+
<ShimmerText>Reading the codebase…</ShimmerText>
8+
</div>
9+
);
10+
11+
export const AsStatus = () => (
12+
<div className="p-4 text-sm">
13+
<ShimmerText role="status">Searching…</ShimmerText>
14+
</div>
15+
);

0 commit comments

Comments
 (0)