Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ bun run stories # Ladle workbench

A new component is not done until it has at least one story per meaningful state, checked in both themes. See [CONTRIBUTING.md](./CONTRIBUTING.md).

## Thinking indicator

The Corbits logo has three motion variants: `silk` (default), `strata`, and `echo`.

```tsx
import { ThinkingIndicator, ThinkingMark } from "@corbits/react-ui";

<ThinkingIndicator variant="silk" />
<ThinkingIndicator variant="strata" label="Reviewing files..." />
<ThinkingMark variant="echo" className="w-8" />
```

`ThinkingIndicator` supplies an accessible status label; `ThinkingMark` is decorative.
Both accept `variant`, `paused`, `className`, and `style`. Reduced-motion users see a
static highlight instead.

## License

LGPL-2.1-only. See [LICENSE](./LICENSE).
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,14 @@
"types": "./dist/ui/theme-toggle.d.ts",
"default": "./dist/ui/theme-toggle.js"
},
"./ui/thinking-indicator": {
"types": "./dist/ui/thinking-indicator.d.ts",
"default": "./dist/ui/thinking-indicator.js"
},
"./ui/thinking-mark": {
"types": "./dist/ui/thinking-mark.d.ts",
"default": "./dist/ui/thinking-mark.js"
},
"./ui/time-series-chart": {
"types": "./dist/ui/time-series-chart.d.ts",
"default": "./dist/ui/time-series-chart.js"
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export * from "./ui/tabs.js";
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-mark.js";
export * from "./ui/time-series-chart.js";
export * from "./ui/toast.js";
export * from "./ui/token-mosaic.js";
Expand Down
29 changes: 29 additions & 0 deletions src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
--secondary-foreground: #2b2627;
--muted: #eceff1;
--muted-foreground: #5c5555;
--thinking-base: #77716a;
--accent: #c5d2de;
--accent-foreground: #2b2627;
--success: #c1d1be;
Expand Down Expand Up @@ -138,6 +139,7 @@
--secondary-foreground: #e4d5bc;
--muted: #1a1718;
--muted-foreground: #a99c86;
--thinking-base: #aaa39a;
--accent: #2d455c;
--accent-foreground: #e4d5bc;
--success: #425a3d;
Expand Down Expand Up @@ -238,6 +240,7 @@
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-thinking-base: var(--thinking-base);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-success: var(--success);
Expand Down Expand Up @@ -533,3 +536,29 @@
outline-offset: 2px;
}
}

/* Ridge-study motion: keep geometry and timing in the original 100 x 70 space. */
@keyframes corbits-thinking-sweep {
0% { transform: translateX(8px); opacity: 0; }
8%, 86% { opacity: 1; }
100% { transform: translateX(130px); opacity: 0; }
}
@keyframes corbits-thinking-rise {
0% { transform: translateY(88px); }
100% { transform: translateY(-30px); }
}
@keyframes corbits-thinking-wave {
0% { transform: scale(.03); opacity: 0; }
10% { opacity: 1; }
75% { opacity: .9; }
100% { transform: scale(1.35); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
/* Static poses land the gradient mid-stroke. These offsets mirror the SVG
geometry in thinking-mark.tsx — change the rect/circle geometry there and
these poses have to be re-picked to match. */
.corbits-thinking-silk, .corbits-thinking-strata, .corbits-thinking-echo { animation: none; }
.corbits-thinking-silk { transform: translateX(44px); }
.corbits-thinking-strata { transform: translateY(30px); }
.corbits-thinking-echo { transform: scale(.6); }
}
37 changes: 37 additions & 0 deletions src/ui/thinking-indicator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";

import { ThinkingIndicator } from "./thinking-indicator.js";
import { ThinkingMark } from "./thinking-mark.js";

describe("ThinkingIndicator", () => {
test("renders an accessible status with a decorative Silk mark", () => {
const html = renderToStaticMarkup(createElement(ThinkingIndicator));

expect(html).toContain('role="status"');
expect(html).toContain("Thinking...");
expect(html).toContain("aria-hidden=\"true\"");
expect(html).toContain("corbits-thinking-silk");
});

test("renders every motion variant", () => {
for (const variant of ["silk", "strata", "echo"] as const) {
const html = renderToStaticMarkup(createElement(ThinkingMark, { variant }));

expect(html).toContain(`corbits-thinking-${variant}`);
}
});

test("paused freezes every animated node", () => {
const html = renderToStaticMarkup(createElement(ThinkingMark, { paused: true }));

expect(html).toContain("animation-play-state:paused");
});

test("echo hides its second wave under reduced motion", () => {
const html = renderToStaticMarkup(createElement(ThinkingMark, { variant: "echo" }));

expect(html).toContain("motion-reduce:hidden");
});
});
18 changes: 18 additions & 0 deletions src/ui/thinking-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cn } from "../lib/utils.js";
import { ThinkingMark, type ThinkingMarkProps } from "./thinking-mark.js";

export interface ThinkingIndicatorProps extends ThinkingMarkProps {
/** Visible status text, also announced by assistive technology. */
label?: string;
}

/** A thinking status with Silk, Strata, or Echo motion. Defaults to Silk. */
export function ThinkingIndicator({ label = "Thinking...", className, style, ...markProps }: ThinkingIndicatorProps) {
return (
<span role="status" className={cn("inline-flex items-center gap-1.5 text-sm text-muted-foreground", className)} style={style}>
{/* The mountain carries more visual weight below its SVG center. */}
<ThinkingMark {...markProps} className="-translate-y-[8%]" />
<span>{label}</span>
</span>
);
}
69 changes: 69 additions & 0 deletions src/ui/thinking-mark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useId, type CSSProperties } from "react";
import { cn } from "../lib/utils.js";
import { CORBITS_MARK_PATH } from "./corbits-mark.js";

export type ThinkingVariant = "silk" | "strata" | "echo";

export interface ThinkingMarkProps {
variant?: ThinkingVariant;
paused?: boolean;
className?: string;
style?: CSSProperties;
}

/** Decorative animated Corbits mark. Pair with visible text or ThinkingIndicator. */
export function ThinkingMark({ variant = "silk", paused = false, className, style }: ThinkingMarkProps) {
const id = useId();
const gradient = `${id}-gradient`;
const mask = `${id}-mask`;
return (
<svg
viewBox="0 0 100 70"
width="30"
height="21"
aria-hidden="true"
focusable="false"
className={cn("inline-block h-auto w-[30px] flex-none overflow-visible", paused && "[&_*]:[animation-play-state:paused]", className)}
style={style}
>
<defs>
{variant === "echo" ? (
<radialGradient id={gradient}>
<stop offset=".38" stopColor="white" stopOpacity="0" />
<stop offset=".65" stopColor="white" stopOpacity=".65" />
<stop offset=".9" stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</radialGradient>
) : (
<linearGradient id={gradient} x1="0" y1="0" x2={variant === "silk" ? "1" : "0"} y2={variant === "silk" ? "0" : "1"}>
<stop stopColor="white" stopOpacity="0" />
{variant === "silk" ? <>
<stop offset=".45" stopColor="white" stopOpacity=".65" />
<stop offset=".78" stopColor="white" />
</> : <>
<stop offset=".25" stopColor="white" />
<stop offset=".42" stopColor="white" stopOpacity=".7" />
</>}
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
)}
<mask id={mask} maskUnits="userSpaceOnUse" x="-10" y="-10" width="120" height="90" style={{ maskType: "luminance" }}>
{variant === "silk" && <rect className="corbits-thinking-silk [animation:corbits-thinking-sweep_1.45s_cubic-bezier(.22,.05,.38,1)_infinite]" x="-35" y="0" width="48" height="75" fill={`url(#${gradient})`} />}
{variant === "strata" && <rect className="corbits-thinking-strata [animation:corbits-thinking-rise_3.2s_cubic-bezier(.3,0,.3,1)_infinite]" x="0" y="-12" width="100" height="33" fill={`url(#${gradient})`} />}
{variant === "echo" && <>
<circle className="corbits-thinking-echo origin-[62px_9px] [animation:corbits-thinking-wave_3.6s_cubic-bezier(.15,.3,.3,1)_infinite]" cx="62" cy="9" r="80" fill={`url(#${gradient})`} />
{/* The delay lives inside the shorthand — a separate animation-delay
utility can be reset by the shorthand's own emission order. */}
<circle className="corbits-thinking-echo origin-[62px_9px] motion-reduce:hidden [animation:corbits-thinking-wave_3.6s_cubic-bezier(.15,.3,.3,1)_-1.8s_infinite]" cx="62" cy="9" r="80" fill={`url(#${gradient})`} />
</>}
</mask>
</defs>
<g className="fill-thinking-base" opacity=".7">
<path d={CORBITS_MARK_PATH} transform="translate(-2.3 -15) scale(.213)" />
</g>
<g className="fill-primary" mask={`url(#${mask})`}>
<path d={CORBITS_MARK_PATH} transform="translate(-2.3 -15) scale(.213)" />
</g>
</svg>
);
}
35 changes: 35 additions & 0 deletions stories/primitives/thinking-indicator.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useState } from "react";
import { ThinkingIndicator } from "../../src/ui/thinking-indicator.js";
import { ThinkingMark, type ThinkingVariant } from "../../src/ui/thinking-mark.js";
import { Button } from "../../src/ui/button.js";

export default { title: "Primitives / Thinking indicator" };

const variants: ThinkingVariant[] = ["silk", "strata", "echo"];

export const AllVariants = () => {
const [paused, setPaused] = useState(false);
return (
<div className="max-w-4xl space-y-6 p-6">
<Button variant="outline" onClick={() => setPaused(!paused)} aria-pressed={paused}>
{paused ? "Play" : "Pause"}
</Button>
<div className="grid gap-6 sm:grid-cols-3">
{variants.map((variant) => (
<section key={variant} className="space-y-8 rounded-xl border bg-card p-6">
<h2 className="text-lg capitalize">{variant}</h2>
<div className="flex h-40 items-center justify-center">
<ThinkingMark variant={variant} paused={paused} className="max-w-full" style={{ width: 180 }} />
</div>
<ThinkingIndicator variant={variant} paused={paused} />
</section>
))}
</div>
</div>
);
};

export const Silk = () => <ThinkingIndicator variant="silk" />;
export const Strata = () => <ThinkingIndicator variant="strata" />;
export const Echo = () => <ThinkingIndicator variant="echo" />;
export const CustomLabel = () => <ThinkingIndicator variant="echo" label="Reviewing your files..." />;
Loading