diff --git a/README.md b/README.md index 103f62e3..8a075988 100644 --- a/README.md +++ b/README.md @@ -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` 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). diff --git a/package.json b/package.json index 3129f69a..362c44c2 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/index.ts b/src/index.ts index 2d6a55d8..0801b2fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/theme.css b/src/theme.css index 04dd236f..d27c648b 100644 --- a/src/theme.css +++ b/src/theme.css @@ -69,6 +69,7 @@ --secondary-foreground: #2b2627; --muted: #eceff1; --muted-foreground: #5c5555; + --thinking-base: #77716a; --accent: #c5d2de; --accent-foreground: #2b2627; --success: #c1d1be; @@ -138,6 +139,7 @@ --secondary-foreground: #e4d5bc; --muted: #1a1718; --muted-foreground: #a99c86; + --thinking-base: #aaa39a; --accent: #2d455c; --accent-foreground: #e4d5bc; --success: #425a3d; @@ -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); @@ -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); } +} diff --git a/src/ui/thinking-indicator.test.tsx b/src/ui/thinking-indicator.test.tsx new file mode 100644 index 00000000..dd4d521b --- /dev/null +++ b/src/ui/thinking-indicator.test.tsx @@ -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"); + }); +}); diff --git a/src/ui/thinking-indicator.tsx b/src/ui/thinking-indicator.tsx new file mode 100644 index 00000000..1f22b367 --- /dev/null +++ b/src/ui/thinking-indicator.tsx @@ -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 ( + + {/* The mountain carries more visual weight below its SVG center. */} + + {label} + + ); +} diff --git a/src/ui/thinking-mark.tsx b/src/ui/thinking-mark.tsx new file mode 100644 index 00000000..f405f49e --- /dev/null +++ b/src/ui/thinking-mark.tsx @@ -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 ( + + ); +} diff --git a/stories/primitives/thinking-indicator.stories.tsx b/stories/primitives/thinking-indicator.stories.tsx new file mode 100644 index 00000000..e42f40b5 --- /dev/null +++ b/stories/primitives/thinking-indicator.stories.tsx @@ -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 ( +
+ +
+ {variants.map((variant) => ( +
+

{variant}

+
+ +
+ +
+ ))} +
+
+ ); +}; + +export const Silk = () => ; +export const Strata = () => ; +export const Echo = () => ; +export const CustomLabel = () => ;