Files
ttstd-infinite-canvas/web/src/components/ai/working-indicator.tsx

83 lines
3.4 KiB
TypeScript

import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* WorkingDots: diagonal wave dot-matrix "busy" indicator.
*
* Form absorbed from boardui agent-thinking (wave variant) — no UI assets or
* extra dependencies; reduced-motion renders a static frame. The grid is drawn
* with `bg-current`, so it inherits whatever text color the caller provides
* (canvas theme accents, CSS vars, etc.).
*/
export function WorkingDots({
cols = 3,
rows = 3,
dotSize = 4,
gap = 2,
tickMs = 90,
minOpacity = 0.14,
trail = 0.32,
className,
}: {
cols?: number;
rows?: number;
dotSize?: number;
gap?: number;
tickMs?: number;
minOpacity?: number;
trail?: number;
className?: string;
}) {
const reducedMotion = useReducedMotion();
const [phase, setPhase] = useState(0);
useEffect(() => {
if (reducedMotion) return;
const timer = window.setInterval(() => setPhase((value) => (value + 1 / 8) % 1), tickMs);
return () => window.clearInterval(timer);
}, [reducedMotion, tickMs]);
const count = cols * rows;
return (
<span aria-hidden className={`grid shrink-0 ${className ?? ""}`} style={{ gridTemplateColumns: `repeat(${cols}, ${dotSize}px)`, gap }}>
{Array.from({ length: count }, (_, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
// Normalized diagonal position, scaled so the wave front turns
// back before reaching the far corner (3/4 for a 3x3 grid,
// matching the original canvas indicator pixel-for-pixel).
const n = Math.max(cols, rows);
const normalized = (col / Math.max(1, cols - 1) + row / Math.max(1, rows - 1)) / 2;
const scalar = normalized * (n / (n + 1));
const behind = (phase - scalar + 1) % 1;
const lit = Math.max(0, 1 - behind / trail) ** 1.5;
const opacity = minOpacity + (1 - minOpacity) * lit;
return <span key={index} className="rounded-[1px] bg-current" style={{ width: dotSize, height: dotSize, opacity }} />;
})}
</span>
);
}
/**
* WorkingGlow: soft breathing halo rendered over a "busy" control (send
* button, composer capsule). Color accepts a hex value or a CSS variable
* (e.g. `var(--creation-text)`); the blurred layer uses color-mix so both
* work. Disabled under prefers-reduced-motion. Caller positions it: wrap the
* control in a `relative` container and pass the matching border radius.
*/
export function WorkingGlow({ active, color, radius = 0, className }: { active: boolean; color: string; radius?: number | string; className?: string }) {
const reducedMotion = useReducedMotion();
if (!active || reducedMotion) return null;
const soft = `color-mix(in srgb, ${color} 55%, transparent)`;
return (
<motion.div
aria-hidden
className={className}
style={{ position: "absolute", inset: 0, borderRadius: radius, pointerEvents: "none", boxShadow: `0 0 0 1px ${color}` }}
animate={{
opacity: [0.35, 1, 0.35],
boxShadow: [`0 0 0 1px ${color}`, `0 0 16px 1px ${soft}, 0 0 0 1px ${color}`, `0 0 0 1px ${color}`],
}}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
);
}