diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 323f3743826..4883e97c0ca 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -97,6 +97,16 @@ const theme = { "border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed", icon: "text-blue-500", }, + // Reserved for the AI agent's "Ask AI" affordance: secondary styling with a + // softened trigger-green border. + "ask-ai": { + textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80", + button: + "bg-secondary border border-[#41FF54]/25 group-hover/button:bg-surface-control group-hover/button:border-[#41FF54]/40 group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", + shortcut: + "border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed", + icon: "text-text-bright", + }, }; type Theme = keyof typeof theme; @@ -112,6 +122,14 @@ function createVariant(sizeName: Size, themeName: Theme) { }; } +// The ask-ai button always leads with the square agent logo, so its padding is +// tuned per size around it: small = 16px logo, 4px left / 6px right; medium +// 32/16 -> 8px; large 40/20 -> 10px. +function createAskAiVariant(sizeName: Size, opticalPadding: string) { + const base = createVariant(sizeName, "ask-ai"); + return { ...base, button: cn(base.button, opticalPadding) }; +} + const variant = { "primary/small": createVariant("small", "primary"), "primary/medium": createVariant("medium", "primary"), @@ -137,6 +155,9 @@ const variant = { "docs/medium": createVariant("medium", "docs"), "docs/large": createVariant("large", "docs"), "docs/extra-large": createVariant("extra-large", "docs"), + "ask-ai/small": createAskAiVariant("small", "px-1 pr-1.5"), + "ask-ai/medium": createAskAiVariant("medium", "px-2"), + "ask-ai/large": createAskAiVariant("large", "px-2.5"), "menu-item": { textColor: "text-text-bright px-1", button: diff --git a/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx b/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx new file mode 100644 index 00000000000..e32b5e1bde1 --- /dev/null +++ b/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx @@ -0,0 +1,609 @@ +import { type CSSProperties, useEffect, useRef } from "react"; + +// Our own 5x5 dot-matrix system, reverse-engineered from dotmatrix +// (github.com/zzzzshawn/matrix) but written from scratch on canvas. +// +// Their architecture, which we keep: +// - Shapes are masks over a fixed 5x5 grid of dots. +// - Animations are a bright "head" walking an ordered route through the +// shape's dots with a falling-off tail (their "Pulse Ladder" snake), over a +// dim base level. +// - Colors are 3-stop gradient presets with a separate glow color. +// +// What we change: +// - Shapes are authored as 5-line string bitmaps and the walk route is derived +// automatically (clockwise by angle from the center). +// - The gradient is evaluated ACROSS the grid at 135deg, so the matrix itself +// shows the sweep. +// - The full 5x5 grid is always visible: unlit cells are faint grey ghosts. +// - METRONOMIC transitions: the head never leaves the grid and never breaks +// its step rhythm. When a shape has run its cycles, the head keeps stepping +// until it lands on a dot SHARED with the next shape, then the next shape's +// route starts from that overlapped dot (route rotated to it) while the base +// dots crossfade. Same rule carries the head out of the rest logo and back +// into it. The default playlist is sequenced so every consecutive pair of +// shapes shares dots. + +export const MATRIX = 5; + +// --- shapes (5-line bitmaps: "o" = dot on) --------------------------------- + +export const DOT_SHAPES = { + // Rest faces — human, invader, cat, alien, robot, ghost. + face: [".....", ".o.o.", ".....", "o...o", ".ooo."], + invader: [".o.o.", "ooooo", "o.o.o", "ooooo", "o...o"], + cat: ["o...o", "ooooo", "o.o.o", "ooooo", ".ooo."], + alien: [".ooo.", "ooooo", "o.o.o", ".ooo.", "..o.."], + robot: ["ooooo", "o.o.o", "ooooo", "o...o", "ooooo"], + ghost: [".ooo.", "ooooo", "o.o.o", "ooooo", "o.o.o"], + // Extended face set. + owl: ["o...o", "ooooo", "o.o.o", ".ooo.", "..o.."], + dog: ["o...o", "ooooo", "o.o.o", "ooooo", "..o.."], + bunny: [".o.o.", ".o.o.", "ooooo", "o.o.o", ".ooo."], + bear: [".o.o.", "ooooo", "o.o.o", "ooooo", ".ooo."], + fox: ["o...o", "oo.oo", "ooooo", ".ooo.", "..o.."], + mouse: ["oo.oo", "ooooo", "o.o.o", ".ooo.", "..o.."], + koala: ["oo.oo", "ooooo", "o.o.o", "ooooo", ".ooo."], + frog: [".o.o.", ".ooo.", "ooooo", "o.o.o", "ooooo"], + penguin: [".ooo.", "ooooo", "o.o.o", "ooooo", "o...o"], + bat: ["o...o", "ooooo", "o.o.o", ".o.o.", "..o.."], + crab: ["o...o", "o.o.o", "ooooo", "ooooo", ".o.o."], + spider: ["o.o.o", ".ooo.", "ooooo", ".ooo.", "o.o.o"], + octopus: [".ooo.", "ooooo", "o.o.o", ".ooo.", "o.o.o"], + skull: [".ooo.", "ooooo", "o.o.o", ".ooo.", ".o.o."], + cyclops: [".ooo.", "ooooo", "oo.oo", ".ooo.", "..o.."], + demon: ["o...o", ".ooo.", "ooooo", "o.o.o", ".ooo."], + clown: ["o.o.o", "ooooo", "o.o.o", "ooooo", ".ooo."], + mech: ["ooooo", "o.o.o", "ooooo", ".o.o.", "ooooo"], + angel: [".ooo.", ".....", "ooooo", "o.o.o", ".ooo."], + pumpkin: ["..o..", ".ooo.", "o.o.o", "ooooo", ".ooo."], + // Cycle shapes. + square: ["ooooo", "o...o", "o...o", "o...o", "ooooo"], + rectH: [".....", "ooooo", "o...o", "ooooo", "....."], + rectV: [".ooo.", ".o.o.", ".o.o.", ".o.o.", ".ooo."], + circle: [".ooo.", "o...o", "o...o", "o...o", ".ooo."], + diamond: ["..o..", ".o.o.", "o...o", ".o.o.", "..o.."], + heart: [".o.o.", "ooooo", "ooooo", ".ooo.", "..o.."], + checker: ["o.o.o", ".o.o.", "o.o.o", ".o.o.", "o.o.o"], +} satisfies Record; + +export type DotShapeName = keyof typeof DOT_SHAPES; + +export const FACE_SHAPES: DotShapeName[] = ["face", "invader", "cat", "alien", "robot", "ghost"]; + +export const EXTRA_FACE_SHAPES: DotShapeName[] = [ + "owl", + "dog", + "bunny", + "bear", + "fox", + "mouse", + "koala", + "frog", + "penguin", + "bat", + "crab", + "spider", + "octopus", + "skull", + "cyclops", + "demon", + "clown", + "mech", + "angel", + "pumpkin", +]; + +// Sequenced so every consecutive pair (including the wrap) shares dots — the +// head hands off between shapes without ever jumping. +export const DEFAULT_PLAYLIST: DotShapeName[] = [ + "square", + "rectH", + "circle", + "diamond", + "checker", + "rectV", + "heart", +]; + +// --- palettes (3-stop gradient + glow, like their color presets) ----------- + +export type DotMatrixPalette = { + /** 3 gradient stops, applied across the grid at 135deg. */ + stops: [string, string, string]; + /** Glow color for bright dots (defaults to the middle stop). */ + glow?: string; +}; + +export const DOT_MATRIX_PALETTES = { + mono: { stops: ["#e2e8f0", "#ffffff", "#94a3b8"], glow: "#ffffff" }, + trigger: { stops: ["#41ff54", "#a4ff53", "#e7ff52"], glow: "#86ff53" }, + aurora: { stops: ["#ff3cac", "#784ba0", "#2b86c5"], glow: "#9c64bf" }, + ocean: { stops: ["#00c6ff", "#0072ff", "#4facfe"], glow: "#2f8fff" }, + sunset: { stops: ["#ff5f6d", "#ffc371", "#ffe29a"], glow: "#ff8b73" }, + neon: { stops: ["#b4ff39", "#39ffb6", "#00d4ff"], glow: "#59ffc8" }, +} satisfies Record; + +export type DotMatrixPaletteName = keyof typeof DOT_MATRIX_PALETTES; + +// --- geometry / precomputation ---------------------------------------------- + +type Rgb = [number, number, number]; + +function hexToRgb(hex: string): Rgb { + let h = hex.replace("#", ""); + if (h.length === 3) { + h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + } + const n = parseInt(h, 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +const mix = (a: number, b: number, t: number) => a + (b - a) * t; +const mixRgb = (a: Rgb, b: Rgb, t: number): Rgb => [ + mix(a[0], b[0], t), + mix(a[1], b[1], t), + mix(a[2], b[2], t), +]; + +const smoothstep = (x: number) => { + const t = Math.min(1, Math.max(0, x)); + return t * t * (3 - 2 * t); +}; + +function parseShape(bitmap: string[]): boolean[] { + const mask = new Array(MATRIX * MATRIX).fill(false); + for (let row = 0; row < MATRIX; row++) { + for (let col = 0; col < MATRIX; col++) { + if (bitmap[row]?.[col] === "o") { + mask[row * MATRIX + col] = true; + } + } + } + return mask; +} + +// Route = the shape's dots ordered clockwise by angle from the center starting +// at the top (ties: outer dots first). This is what the bright head walks. +function buildRoute(mask: boolean[]): number[] { + const center = (MATRIX - 1) / 2; + const cells: { index: number; angle: number; radius: number }[] = []; + for (let index = 0; index < mask.length; index++) { + if (!mask[index]) continue; + const row = Math.floor(index / MATRIX); + const col = index % MATRIX; + const dx = col - center; + const dy = row - center; + // 0 at 12 o'clock, increasing clockwise. + const angle = (Math.atan2(dx, -dy) + 2 * Math.PI) % (2 * Math.PI); + cells.push({ index, angle, radius: Math.hypot(dx, dy) }); + } + cells.sort((a, b) => a.angle - b.angle || b.radius - a.radius); + return cells.map((c) => c.index); +} + +// Rotate a cyclic route so it starts at `startCell` (falls back unrotated). +function rotateRoute(route: number[], startCell: number): number[] { + const at = route.indexOf(startCell); + if (at <= 0) return route; + return [...route.slice(at), ...route.slice(0, at)]; +} + +// Sample the 3-stop ramp at a continuous 0..1 position along the 135deg sweep. +function sampleRamp(stops: [Rgb, Rgb, Rgb], t: number): Rgb { + const clamped = Math.min(1, Math.max(0, t)); + return clamped <= 0.5 + ? mixRgb(stops[0], stops[1], clamped * 2) + : mixRgb(stops[1], stops[2], (clamped - 0.5) * 2); +} + +function paletteColors(stops: [Rgb, Rgb, Rgb]): Rgb[] { + const colors: Rgb[] = []; + for (let index = 0; index < MATRIX * MATRIX; index++) { + const row = Math.floor(index / MATRIX); + const col = index % MATRIX; + colors.push(sampleRamp(stops, (col / (MATRIX - 1) + row / (MATRIX - 1)) / 2)); + } + return colors; +} + +// Their Pulse Ladder falloff. Applied over the head's recent HISTORY (not +// route math), so the tail trails naturally through shape handoffs. +const SNAKE_TAIL = [1, 0.82, 0.68, 0.54, 0.42, 0.31, 0.22, 0.14]; + +// --- component --------------------------------------------------------------- + +export type AgentDotMatrixProps = { + /** Rendered size in CSS pixels. */ + size?: number; + /** false = static rest shape; true = animate through the playlist. */ + active?: boolean; + /** The resting shape (the "logo"). */ + restShape?: DotShapeName; + /** Shapes cycled through while thinking (consecutive shapes should overlap). */ + playlist?: DotShapeName[]; + /** Full route cycles each shape runs before handing off. */ + cyclesPerShape?: number; + /** 3-stop gradient palette (name or custom) used while thinking. */ + palette?: DotMatrixPaletteName | DotMatrixPalette; + /** Rest-state dot color; "palette" (default) themes the logo with the gradient. */ + restColor?: string | "palette"; + /** Opacity pair: dim base for shape dots, peak for the head/rest logo. */ + opacityBase?: number; + opacityPeak?: number; + /** + * Which background the matrix sits on. The ghost grid is white ink at low + * opacity for dark surfaces (reads as subtle grey) and black ink for light. + */ + mode?: "dark" | "light"; + /** Opacity of the ghost grid (0 disables it). */ + gridOpacity?: number; + /** + * When false (default), the ghost grid is transparent while resting (only + * the logo's dots show) and fades in while thinking. True: always visible. + */ + gridAtRest?: boolean; + /** Animation speed multiplier. */ + speed?: number; + className?: string; + style?: CSSProperties; + "aria-label"?: string; +}; + +const STEP_MS = 60; +const BLEND_MS = 240; + +export function AgentDotMatrix({ + size = 20, + active = false, + restShape = "ghost", + playlist = DEFAULT_PLAYLIST, + cyclesPerShape = 2, + palette = "trigger", + restColor = "palette", + opacityBase = 0.3, + opacityPeak = 0.95, + mode = "dark", + gridOpacity = 0.18, + gridAtRest = false, + speed = 1, + className, + style, + "aria-label": ariaLabel, +}: AgentDotMatrixProps) { + const canvasRef = useRef(null); + const activeRef = useRef(active); + const wakeRef = useRef<() => void>(() => {}); + + const paletteObj: DotMatrixPalette = + typeof palette === "string" ? DOT_MATRIX_PALETTES[palette] : palette; + const paletteKey = paletteObj.stops.join(",") + (paletteObj.glow ?? ""); + const playlistKey = playlist.join(","); + + useEffect(() => { + activeRef.current = active; + wakeRef.current(); + }, [active]); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + + const dpr = Math.min(2, typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1); + canvas.width = Math.round(size * dpr); + canvas.height = Math.round(size * dpr); + + const shapeNames: DotShapeName[] = [restShape, ...playlist]; + const masks = new Map(); + const routes = new Map(); + for (const name of shapeNames) { + const mask = parseShape(DOT_SHAPES[name]); + masks.set(name, mask); + routes.set(name, buildRoute(mask)); + } + const stops = paletteObj.stops.map(hexToRgb) as [Rgb, Rgb, Rgb]; + const gridColors = paletteColors(stops); + const glowRgb = hexToRgb(paletteObj.glow ?? paletteObj.stops[1]); + const restRgb: Rgb[] = + restColor === "palette" ? gridColors : new Array(MATRIX * MATRIX).fill(hexToRgb(restColor)); + const restMask = masks.get(restShape)!; + + const pitch = size / MATRIX; + const dotR = Math.max(0.75, pitch * 0.3); + const stepMs = STEP_MS / speed; + const blendMs = BLEND_MS / speed; + // White ink at low opacity reads as subtle grey on any dark background; + // light mode flips to black ink. + const gridRgb: Rgb = mode === "light" ? [0, 0, 0] : [255, 255, 255]; + + // --- engine state (refs only; no React re-renders) --- + // The head is metronomic: while running it moves exactly one route dot per + // step, including straight through shape handoffs (shared dot = no jump). + let running_shape = false; // false = at rest (no head) + let route: number[] = []; // current (rotated) route + let playlistIdx = -1; + let headPos = 0; // index into route + let stepsInShape = 0; + let stepClock = 0; + let seeking: "next" | "rest" | null = null; + let seekSteps = 0; + const history: number[] = []; // recent head cells, newest first + let tailFade = 1; // ramps to 0 while settling back to rest + let gridFade = gridAtRest ? 1 : 0; // ghost-grid visibility (fades with activity) + let glowFade = 0; // dot glow only while animating — static logos have no glow + // Base crossfade between the previous and current shape's dim base maps. + let blendT = 1; + const baseFrom = new Float64Array(MATRIX * MATRIX); + const blendFrom = new Float64Array(MATRIX * MATRIX); + const baseTo = new Float64Array(MATRIX * MATRIX); + const blendTo = new Float64Array(MATRIX * MATRIX); + + const setTargetMaps = (m: boolean[], level: number, colorBlend: number) => { + for (let i = 0; i < baseTo.length; i++) { + baseTo[i] = m[i] ? level : 0; + blendTo[i] = m[i] ? colorBlend : 0; + } + }; + + const snapshotToFrom = () => { + const t = smoothstep(blendT); + for (let i = 0; i < baseFrom.length; i++) { + baseFrom[i] = mix(baseFrom[i], baseTo[i], t); + blendFrom[i] = mix(blendFrom[i], blendTo[i], t); + } + blendT = 0; + }; + + // Initial pose: rest logo, fully blended. + setTargetMaps(restMask, opacityPeak, 0); + baseFrom.set(baseTo); + blendFrom.set(blendTo); + + // Shared-dot handoff: rotate the target's route to start at the head's + // current cell (guaranteed shared by the playlist sequencing; nearest-dot + // fallback keeps custom configs safe). + const handoffTo = (target: DotShapeName, targetIdx: number, atCell: number) => { + const targetMask = masks.get(target)!; + const canonical = routes.get(target)!; + const startCell = targetMask[atCell] + ? atCell + : canonical.reduce( + (best, cell) => + Math.hypot( + (cell % MATRIX) - (atCell % MATRIX), + Math.floor(cell / MATRIX) - Math.floor(atCell / MATRIX) + ) < + Math.hypot( + (best % MATRIX) - (atCell % MATRIX), + Math.floor(best / MATRIX) - Math.floor(atCell / MATRIX) + ) + ? cell + : best, + canonical[0] + ); + route = rotateRoute(canonical, startCell); + playlistIdx = targetIdx; + headPos = 0; + stepsInShape = 0; + seeking = null; + seekSteps = 0; + snapshotToFrom(); + setTargetMaps(targetMask, opacityBase, 1); + if (startCell !== history[0]) { + history.unshift(startCell); + if (history.length > SNAKE_TAIL.length) history.length = SNAKE_TAIL.length; + } + }; + + const settleToRest = () => { + running_shape = false; + playlistIdx = -1; + seeking = null; + snapshotToFrom(); + setTargetMaps(restMask, opacityPeak, 0); + }; + + const beginThinking = () => { + const first = playlist[0]; + // Spawn the head on a dot shared between the rest logo and the first + // shape — earliest such dot in the shape's canonical route. + const canonical = routes.get(first)!; + const spawn = canonical.find((cell) => restMask[cell]) ?? canonical[0]; + history.length = 0; + history.unshift(spawn); + tailFade = 1; + running_shape = true; + handoffTo(first, 0, spawn); + }; + + // Draw in device-pixel space with centers/radii snapped to whole device + // pixels — dot positions never animate (only opacity), and unsnapped + // fractional centers (e.g. 14px -> 2.8px pitch) blur every dot differently. + // Light mode gets a size boost (snapped to half pixels): dark-on-light dots + // read optically smaller than the same-size light-on-dark dots. + const devCenter = (cell: number) => Math.round((cell + 0.5) * pitch * dpr); + const devDotR = + mode === "light" + ? Math.max(1, Math.round(dotR * dpr * 1.15 * 2) / 2) + : Math.max(1, Math.round(dotR * dpr)); + + const draw = () => { + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + const t = smoothstep(blendT); + for (let index = 0; index < MATRIX * MATRIX; index++) { + let alpha = mix(baseFrom[index], baseTo[index], t); + let cBlend = mix(blendFrom[index], blendTo[index], t); + // Tail overlay from the head's recent history (trails through handoffs). + if (tailFade > 0.01) { + const d = history.indexOf(index); + if (d >= 0 && d < SNAKE_TAIL.length) { + const level = mix(opacityBase, opacityPeak, SNAKE_TAIL[d]) * tailFade; + if (level > alpha) { + alpha = level; + cBlend = Math.max(cBlend, tailFade); + } + } + } + const row = Math.floor(index / MATRIX); + const col = index % MATRIX; + const x = devCenter(col); + const y = devCenter(row); + ctx.beginPath(); + if (alpha <= gridOpacity) { + const ghostAlpha = gridOpacity * gridFade; + if (ghostAlpha <= 0.004) continue; + ctx.shadowBlur = 0; + ctx.fillStyle = `rgba(${gridRgb[0]},${gridRgb[1]},${gridRgb[2]},${ghostAlpha.toFixed(3)})`; + ctx.arc(x, y, devDotR, 0, Math.PI * 2); + ctx.fill(); + continue; + } + const rgb = mixRgb(restRgb[index], gridColors[index], cBlend); + ctx.fillStyle = `rgba(${rgb[0] | 0},${rgb[1] | 0},${rgb[2] | 0},${alpha.toFixed(3)})`; + const glowAlpha = alpha >= 0.6 ? ((alpha - 0.6) / 0.4) * glowFade : 0; + if (glowAlpha > 0.01) { + ctx.shadowColor = `rgba(${glowRgb[0]},${glowRgb[1]},${glowRgb[2]},${glowAlpha.toFixed(3)})`; + ctx.shadowBlur = devDotR * 2.2; + } else { + ctx.shadowBlur = 0; + } + ctx.arc(x, y, devDotR, 0, Math.PI * 2); + ctx.fill(); + } + ctx.shadowBlur = 0; + }; + + const reduced = + typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduced) { + draw(); + return; + } + + let raf = 0; + let running = false; + let last = 0; + + // One metronome step: move the head one dot, then apply the transition + // rules — (1) enough cycles? (2) on a shared dot? (3) hand off from it. + const stepOnce = () => { + headPos = (headPos + 1) % route.length; + stepsInShape++; + history.unshift(route[headPos]); + if (history.length > SNAKE_TAIL.length) history.length = SNAKE_TAIL.length; + + if (!activeRef.current) { + seeking = "rest"; + } else if (seeking !== "next" && stepsInShape >= cyclesPerShape * route.length) { + seeking = "next"; + } + if (!seeking) return; + + seekSteps++; + const headCell = route[headPos]; + if (seeking === "rest") { + if (restMask[headCell] || seekSteps > route.length + 1) { + settleToRest(); + } + } else { + const nextIdx = (playlistIdx + 1) % playlist.length; + const nextMask = masks.get(playlist[nextIdx])!; + if (nextMask[headCell] || seekSteps > route.length + 1) { + handoffTo(playlist[nextIdx], nextIdx, headCell); + } + } + }; + + const frame = (now: number) => { + const dt = Math.min(80, now - last); + last = now; + let keepGoing = true; + + // The ghost grid follows activity when gridAtRest is off; the glow always does. + const gridTarget = gridAtRest || running_shape ? 1 : 0; + if (gridFade < gridTarget) { + gridFade = Math.min(gridTarget, gridFade + dt / blendMs); + } else if (gridFade > gridTarget) { + gridFade = Math.max(gridTarget, gridFade - dt / blendMs); + } + const glowTarget = running_shape ? 1 : 0; + if (glowFade < glowTarget) { + glowFade = Math.min(glowTarget, glowFade + dt / blendMs); + } else if (glowFade > glowTarget) { + glowFade = Math.max(glowTarget, glowFade - dt / blendMs); + } + + if (!running_shape) { + if (activeRef.current && playlist.length > 0) { + beginThinking(); + } else { + // Settling home: finish the crossfade, fade the tail + grid + glow, then stop. + blendT = Math.min(1, blendT + dt / blendMs); + tailFade = Math.max(0, tailFade - dt / blendMs); + if (blendT >= 1 && tailFade <= 0 && gridFade === gridTarget && glowFade === 0) { + keepGoing = false; + } + } + } + + if (running_shape) { + blendT = Math.min(1, blendT + dt / blendMs); + stepClock += dt; + while (stepClock >= stepMs && running_shape) { + stepClock -= stepMs; + stepOnce(); + } + } + + draw(); + + if (keepGoing) { + raf = requestAnimationFrame(frame); + } else { + running = false; + } + }; + + const wake = () => { + if (running) return; + running = true; + last = typeof performance !== "undefined" ? performance.now() : 0; + raf = requestAnimationFrame(frame); + }; + wakeRef.current = wake; + + draw(); + wake(); + + return () => { + running = false; + wakeRef.current = () => {}; + cancelAnimationFrame(raf); + }; + }, [ + size, + speed, + restShape, + playlistKey, + cyclesPerShape, + paletteKey, + restColor, + opacityBase, + opacityPeak, + mode, + gridOpacity, + gridAtRest, + ]); + + return ( + + ); +} diff --git a/apps/webapp/app/routes/storybook.ai-agent/AgentOrb.tsx b/apps/webapp/app/routes/storybook.ai-agent/AgentOrb.tsx new file mode 100644 index 00000000000..82407550f54 --- /dev/null +++ b/apps/webapp/app/routes/storybook.ai-agent/AgentOrb.tsx @@ -0,0 +1,767 @@ +import { type CSSProperties, useEffect, useMemo, useRef } from "react"; +import { LogoIcon } from "~/components/LogoIcon"; + +// Phase 1 prototype of the trigger.dev "AI dashboard agent" orb. +// +// Rest state = a dotted logo (a triangle outline, or the trigger.dev mark filled +// with a dot cloud). Active state = the "working" animation deconstructed from +// thinking-orbs: dots on tilted 3D orbital rings, each with faint "ghost" dots +// tracing the ring plus a few bright particles orbiting it. +// +// A single eased `activation` (0 = rest, 1 = working) interpolates EVERY dot's +// position, radius, depth, alpha and colour between the two layouts — one shared +// pool of dots morphing, not a cross-fade. Colour ramps linearly white -> palette. + +// --- tunables ------------------------------------------------------------- + +const TRIANGLE_SPREAD = 1.45; // scales the triangle to fill the box +const SHELL = 0.82; // orbit shell radius as a fraction of size/2 +const TILT = 0.3; // fixed X-axis tilt of the orbit system (radians) +const SPIN_RATE = 0.12; // how fast the whole system rotates about Y, per time unit + +// Tailwind-500 palette (blue, rose, green, amber). +export const AGENT_ORB_PALETTE = ["#3b82f6", "#f43f5e", "#22c55e", "#f59e0b"]; + +// The trigger.dev logo path (from LogoIcon), used to shape the dense dot cloud. +const LOGO_PATH = + "M96.1017 113.4L160.679 4.57764e-05L320.718 281.045H0.638916L65.2159 167.642L110.896 194.382L92.0035 227.561H229.354L160.679 106.965L141.786 140.144L96.1017 113.4Z"; +const LOGO_W = 321; +const LOGO_H = 282; + +// --- pure geometry -------------------------------------------------------- + +type Vec3 = [number, number, number]; +type Pt = [number, number]; + +// Deterministic hash (same as the library's) so orbit orientations are stable. +function hash(i: number, seed: number): number { + const s = Math.sin(i * 12.9898 + seed * 78.233) * 43758.5453; + return s - Math.floor(s); +} + +type OrbitGeom = { + basisA: Vec3; // two orthonormal in-plane vectors spanning the ring + basisB: Vec3; + radiusFactor: number; // ring radius as a fraction of the shell + speed: number; // particle angular speed + direction + phase: number; // shared angular offset for this ring's particles +}; + +function buildOrbitGeoms(orbitCount: number): OrbitGeom[] { + return Array.from({ length: orbitCount }, (_, b) => { + const x = hash(b, 1.7); + const i = hash(b, 5.2); + const p = hash(b, 8.9); + const azimuth = x * 2 * Math.PI; + const polar = Math.acos(2 * i - 1); + const nx = Math.sin(polar) * Math.cos(azimuth); + const ny = Math.cos(polar); + const nz = Math.sin(polar) * Math.sin(azimuth); + let ax = -ny; + let ay = nx; + const len = Math.max(1e-6, Math.hypot(ax, ay)); + ax /= len; + ay /= len; + const basisA: Vec3 = [ax, ay, 0]; + const basisB: Vec3 = [-nz * ay, nz * ax, nx * ay - ny * ax]; // normal × basisA + return { + basisA, + basisB, + radiusFactor: 0.45 + 0.52 * x, + speed: (0.25 + 0.55 * p) * (p > 0.5 ? 1 : -1), + phase: i * 6, + }; + }); +} + +type DotSpec = { + orbit: number; + ghost: boolean; + ringAngle: number; +}; + +// Assign each dot to an orbit (round-robin so rings stay balanced at any count); +// the first `particlesPerOrbit` dots of each ring are bright particles, the rest +// are faint ghosts. Angles are spread evenly around each ring. +function buildDotSpecs(dotCount: number, orbitCount: number, particlesPerOrbit: number): DotSpec[] { + const members: number[][] = Array.from({ length: orbitCount }, () => []); + for (let i = 0; i < dotCount; i++) { + members[i % orbitCount].push(i); + } + const specs: DotSpec[] = new Array(dotCount); + for (let b = 0; b < orbitCount; b++) { + const ring = members[b]; + for (let k = 0; k < ring.length; k++) { + specs[ring[k]] = { + orbit: b, + ghost: k >= particlesPerOrbit, + ringAngle: (k / Math.max(1, ring.length)) * 2 * Math.PI, + }; + } + } + return specs; +} + +// Arc-length parameterizer for a closed polygon: f in [0,1] -> point spaced by +// perimeter distance so dots are evenly distributed. +function makePolygonSampler(points: Pt[]): (f: number) => Pt { + const seg: number[] = []; + let total = 0; + for (let i = 0; i < points.length; i++) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + const d = Math.hypot(b[0] - a[0], b[1] - a[1]); + seg.push(d); + total += d; + } + return (f) => { + let dist = f * total; + let i = 0; + while (dist > seg[i] && i < seg.length - 1) { + dist -= seg[i]; + i++; + } + const a = points[i]; + const b = points[(i + 1) % points.length]; + const t = seg[i] ? Math.min(1, dist / seg[i]) : 0; + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + }; +} + +const triangleAt = makePolygonSampler([ + [0, -0.26], + [0.24, 0.16], + [-0.24, 0.16], +]); + +// Rest positions in "fraction of size" units (render maps center + pt * size). +function triangleOutline(count: number): Pt[] { + const pts: Pt[] = []; + for (let i = 0; i < count; i++) { + const [tx, ty] = triangleAt(i / count); + pts.push([tx * TRIANGLE_SPREAD, ty * TRIANGLE_SPREAD]); + } + return pts; +} + +// Rejection-sample `count` points inside the trigger.dev logo silhouette, so a +// dense cloud reads as the solid logo at small sizes. Cached per count (uses a +// canvas hit-test, so client-only; returns [] during SSR). +const logoCloudCache = new Map(); +function logoCloud(count: number): Pt[] { + if (typeof document === "undefined") return []; + const cached = logoCloudCache.get(count); + if (cached) return cached; + + const ctx = document.createElement("canvas").getContext("2d"); + if (!ctx) return []; + const path = new Path2D(LOGO_PATH); + const scale = 0.9 / Math.max(LOGO_W, LOGO_H); // fit to ~0.9 box, preserve aspect + const pts: Pt[] = []; + const maxAttempts = count * 400; + let attempts = 0; + while (pts.length < count && attempts < maxAttempts) { + attempts++; + const x = Math.random() * LOGO_W; + const y = Math.random() * LOGO_H; + if (ctx.isPointInPath(path, x, y, "evenodd")) { + pts.push([(x - LOGO_W / 2) * scale, (y - LOGO_H / 2) * scale]); + } + } + logoCloudCache.set(count, pts); + return pts; +} + +// Project a 3D ring point to screen space (+ depth). Same math as the library's `q`. +function project(x: number, y: number, z: number, spin: number, center: number): Vec3 { + const st = Math.sin(TILT); + const ct = Math.cos(TILT); + const ss = Math.sin(spin); + const cs = Math.cos(spin); + const u = x * cs + z * ss; + const h = -x * ss + z * cs; + const b = y * ct - h * st; + const depth = y * st + h * ct; + return [center + u, center - b, depth]; +} + +function hexToRgb(hex: string): [number, number, number] { + let h = hex.replace("#", ""); + if (h.length === 3) { + h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + } + const n = parseInt(h, 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +const mix = (a: number, b: number, t: number) => a + (b - a) * t; + +// Size-adaptive dots: small orbs get fewer, chunkier dots so they stay legible; +// larger orbs keep the denser, finer look. (48px+ matches the non-adaptive 12.) +function adaptiveDots(size: number): { + dotCount: number; + particlesPerOrbit: number; + radiusScale: number; +} { + if (size <= 24) return { dotCount: 6, particlesPerOrbit: 2, radiusScale: 1.7 }; + if (size <= 40) return { dotCount: 9, particlesPerOrbit: 3, radiusScale: 1.25 }; + return { dotCount: 12, particlesPerOrbit: 3, radiusScale: 1 }; +} + +// --- the per-frame painter ------------------------------------------------ + +type RenderConfig = { + size: number; + restPoints: Pt[]; + dotSpecs: DotSpec[]; + orbitGeoms: OrbitGeom[]; + paletteRgb: [number, number, number][]; + restRgb: [number, number, number]; + colored: boolean; + radiusScale: number; +}; + +function drawFrame( + ctx: CanvasRenderingContext2D, + dpr: number, + activation: number, + time: number, + cfg: RenderConfig +) { + const { size, restPoints, dotSpecs, orbitGeoms, paletteRgb, restRgb, colored, radiusScale } = cfg; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, size, size); + + const center = size / 2; + const shell = (size / 2) * SHELL; + const spin = time * SPIN_RATE; + const triRadius = Math.max(0.55, size * 0.03) * radiusScale; + + const dots: { x: number; y: number; r: number; z: number; fill: string }[] = []; + + for (let i = 0; i < dotSpecs.length; i++) { + const home = restPoints[i]; + if (!home) continue; + const spec = dotSpecs[i]; + const geom = orbitGeoms[spec.orbit]; + + const homeX = center + home[0] * size; + const homeY = center + home[1] * size; + + // Live orbit point (3D -> screen). + const ringR = shell * geom.radiusFactor; + const angle = spec.ghost ? spec.ringAngle : time * geom.speed + spec.ringAngle + geom.phase; + const ca = Math.cos(angle); + const sa = Math.sin(angle); + const px = (geom.basisA[0] * ca + geom.basisB[0] * sa) * ringR; + const py = (geom.basisA[1] * ca + geom.basisB[1] * sa) * ringR; + const pz = (geom.basisA[2] * ca + geom.basisB[2] * sa) * ringR; + const [sx, sy, depth] = project(px, py, pz, spin, center); + const front = Math.min(1, Math.max(0, (depth / ringR + 1) / 2)); // 0 back .. 1 front + + const orbitRadius = spec.ghost + ? Math.max(0.4, size * 0.022) * radiusScale + : Math.max(0.5, size * (0.026 + 0.03 * front)) * radiusScale; + const orbitAlpha = spec.ghost ? 0.5 * (0.35 + 0.65 * front) : 0.6 + 0.4 * front; + + // Interpolate rest -> orbit by activation. + const x = mix(homeX, sx, activation); + const y = mix(homeY, sy, activation); + const r = mix(triRadius, orbitRadius, activation); + const alpha = mix(1, orbitAlpha, activation); + + let cr = restRgb[0]; + let cg = restRgb[1]; + let cb = restRgb[2]; + if (colored) { + const target = paletteRgb[i % paletteRgb.length]; + cr = Math.round(mix(restRgb[0], target[0], activation)); + cg = Math.round(mix(restRgb[1], target[1], activation)); + cb = Math.round(mix(restRgb[2], target[2], activation)); + } + + dots.push({ x, y, r, z: depth * activation, fill: `rgba(${cr},${cg},${cb},${alpha})` }); + } + + dots.sort((a, b) => a.z - b.z); // far dots first (no-op at rest since z = depth * 0) + for (const d of dots) { + ctx.fillStyle = d.fill; + ctx.beginPath(); + ctx.arc(d.x, d.y, Math.max(0.3, d.r), 0, Math.PI * 2); + ctx.fill(); + } +} + +// --- component ------------------------------------------------------------ + +export type AgentOrbProps = { + /** Rendered size in CSS pixels. Tuned to look right down to ~20px. */ + size?: number; + /** Drives the morph: false = static rest shape, true = working animation. */ + active?: boolean; + /** Controlled override of the morph amount (0..1); ignores `active` when set. */ + activation?: number; + /** Per-dot colours shown while working (cycled by index). */ + colors?: string[]; + /** Ink colour at rest, and for every dot when `colored` is false. */ + restColor?: string; + /** When false, dots stay `restColor` (white) — only alpha/size/position animate. */ + colored?: boolean; + /** Rest layout: a triangle outline, or a dense fill of the trigger.dev logo. */ + restShape?: "triangle" | "logo"; + /** Total dots in the shared pool. More dots + `logo` shape = solid-logo look. */ + dotCount?: number; + /** Number of orbital rings in the working state. */ + orbitCount?: number; + /** Bright orbiting particles per ring (the rest are faint ghosts). */ + particlesPerOrbit?: number; + /** Auto-tune dot count + dot size to `size` (fewer, chunkier dots when small). */ + adaptive?: boolean; + /** Orbit speed multiplier (3.9 matches the library's 20px "working"). */ + speed?: number; + /** Morph duration when `active` toggles, in ms. */ + transitionMs?: number; + className?: string; + style?: CSSProperties; + "aria-label"?: string; +}; + +export function AgentOrb({ + size = 20, + active = false, + activation, + colors = AGENT_ORB_PALETTE, + restColor = "#ffffff", + colored = true, + restShape = "triangle", + dotCount = 21, + orbitCount = 3, + particlesPerOrbit = 3, + adaptive = false, + speed = 3.9, + transitionMs = 600, + className, + style, + "aria-label": ariaLabel, +}: AgentOrbProps) { + const canvasRef = useRef(null); + + const activeRef = useRef(active); + const controlledRef = useRef(activation); + const amountRef = useRef(activation ?? (active ? 1 : 0)); + const timeRef = useRef(0); + const wakeRef = useRef<() => void>(() => {}); + + const colorsKey = colors.join(","); + const adaptiveParams = adaptive ? adaptiveDots(size) : null; + const effDotCount = adaptiveParams?.dotCount ?? dotCount; + const effParticles = adaptiveParams?.particlesPerOrbit ?? particlesPerOrbit; + const radiusScale = adaptiveParams?.radiusScale ?? 1; + const orbitGeoms = useMemo(() => buildOrbitGeoms(orbitCount), [orbitCount]); + const dotSpecs = useMemo( + () => buildDotSpecs(effDotCount, orbitCount, effParticles), + [effDotCount, orbitCount, effParticles] + ); + const restPoints = useMemo( + () => (restShape === "logo" ? logoCloud(effDotCount) : triangleOutline(effDotCount)), + [restShape, effDotCount] + ); + + useEffect(() => { + activeRef.current = active; + controlledRef.current = activation; + wakeRef.current(); + }, [active, activation]); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + + const dpr = Math.min(2, typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1); + canvas.width = Math.round(size * dpr); + canvas.height = Math.round(size * dpr); + + const cfg: RenderConfig = { + size, + restPoints, + dotSpecs, + orbitGeoms, + paletteRgb: colors.map(hexToRgb), + restRgb: hexToRgb(restColor), + colored, + radiusScale, + }; + + const reduced = + typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches; + + if (reduced) { + drawFrame(ctx, dpr, 0, 0, cfg); + return; + } + + let raf = 0; + let running = false; + let last = 0; + + const frame = (now: number) => { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + + const controlled = controlledRef.current; + let keepGoing: boolean; + + if (controlled != null) { + amountRef.current = controlled; + keepGoing = controlled > 0; + } else { + const target = activeRef.current ? 1 : 0; + const step = transitionMs > 0 ? (dt * 1000) / transitionMs : 1; + if (amountRef.current < target) { + amountRef.current = Math.min(target, amountRef.current + step); + } else if (amountRef.current > target) { + amountRef.current = Math.max(target, amountRef.current - step); + } + keepGoing = amountRef.current !== target || target === 1; + } + + if (amountRef.current > 0) { + timeRef.current += dt * speed; + } + drawFrame(ctx, dpr, amountRef.current, timeRef.current, cfg); + + if (keepGoing) { + raf = requestAnimationFrame(frame); + } else { + running = false; + } + }; + + const wake = () => { + if (running) return; + running = true; + last = typeof performance !== "undefined" ? performance.now() : 0; + raf = requestAnimationFrame(frame); + }; + wakeRef.current = wake; + + drawFrame(ctx, dpr, amountRef.current, timeRef.current, cfg); + wake(); + + return () => { + running = false; + wakeRef.current = () => {}; + cancelAnimationFrame(raf); + }; + }, [ + size, + speed, + transitionMs, + colorsKey, + restColor, + colored, + radiusScale, + orbitGeoms, + dotSpecs, + restPoints, + ]); + + return ( + + ); +} + +// --- crisp logo -> orbits variant ----------------------------------------- +// +// Rest = the real crisp LogoIcon (a separate SVG layer). The 21 dots are +// anchored to the logo's actual outline and born in its green, so as the solid +// mark fades they materialise on its edge, then fly out to the orbits and bloom +// into the palette. Two timing windows make the handoff seamless: +// 0.00 - 0.30 logo fades out; dots fade in, held still on the outline (green) +// 0.30 - 1.00 dots fly outline -> orbit, bloom green -> palette, gain depth + +const smoothstep = (edge0: number, edge1: number, x: number) => { + const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +}; + +// 21 points evenly along the logo outline via the real vector path, normalized +// to the same box the crisp LogoIcon fills (width-limited, letterboxed). Cached +// per count; client-only (needs an SVG in the DOM to measure). +const LOGO_OUTLINE_CACHE = new Map(); +function logoOutlinePoints(count: number): Pt[] { + if (typeof document === "undefined") return []; + const cached = LOGO_OUTLINE_CACHE.get(count); + if (cached) return cached; + + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("style", "position:absolute;left:-9999px;width:0;height:0;overflow:hidden"); + const path = document.createElementNS(ns, "path") as SVGPathElement; + path.setAttribute("d", LOGO_PATH); + svg.appendChild(path); + document.body.appendChild(svg); + + const total = path.getTotalLength(); + const yOffset = (1 - LOGO_H / LOGO_W) / 2; // LogoIcon is width-limited, so letterboxed vertically + const pts: Pt[] = []; + for (let i = 0; i < count; i++) { + const p = path.getPointAtLength((i / count) * total); + pts.push([p.x / LOGO_W, p.y / LOGO_W + yOffset]); + } + document.body.removeChild(svg); + LOGO_OUTLINE_CACHE.set(count, pts); + return pts; +} + +type LogoMorphConfig = { + size: number; + outline: Pt[]; + dotSpecs: DotSpec[]; + orbitGeoms: OrbitGeom[]; + paletteRgb: [number, number, number][]; + logoRgb: [number, number, number]; +}; + +function drawLogoMorphFrame( + ctx: CanvasRenderingContext2D, + dpr: number, + activation: number, + time: number, + cfg: LogoMorphConfig +) { + const { size, outline, dotSpecs, orbitGeoms, paletteRgb, logoRgb } = cfg; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, size, size); + + const dotOpacity = smoothstep(0.05, 0.3, activation); + if (dotOpacity <= 0.001) return; // rest: only the crisp logo shows + + const fly = smoothstep(0.3, 1, activation); // outline -> orbit + const colorAmount = smoothstep(0.35, 1, activation); // green -> palette + const center = size / 2; + const shell = (size / 2) * SHELL; + const spin = time * SPIN_RATE; + const restRadius = Math.max(0.55, size * 0.03); + + const dots: { x: number; y: number; r: number; z: number; fill: string }[] = []; + for (let i = 0; i < dotSpecs.length; i++) { + const home = outline[i]; + if (!home) continue; + const spec = dotSpecs[i]; + const geom = orbitGeoms[spec.orbit]; + + const homeX = home[0] * size; // absolute fraction, aligned to the crisp logo + const homeY = home[1] * size; + + const ringR = shell * geom.radiusFactor; + const angle = spec.ghost ? spec.ringAngle : time * geom.speed + spec.ringAngle + geom.phase; + const ca = Math.cos(angle); + const sa = Math.sin(angle); + const px = (geom.basisA[0] * ca + geom.basisB[0] * sa) * ringR; + const py = (geom.basisA[1] * ca + geom.basisB[1] * sa) * ringR; + const pz = (geom.basisA[2] * ca + geom.basisB[2] * sa) * ringR; + const [sx, sy, depth] = project(px, py, pz, spin, center); + const front = Math.min(1, Math.max(0, (depth / ringR + 1) / 2)); + + const orbitRadius = spec.ghost + ? Math.max(0.4, size * 0.022) + : Math.max(0.5, size * (0.026 + 0.03 * front)); + const orbitAlpha = spec.ghost ? 0.5 * (0.35 + 0.65 * front) : 0.6 + 0.4 * front; + + const x = mix(homeX, sx, fly); + const y = mix(homeY, sy, fly); + const r = mix(restRadius, orbitRadius, fly); + const alpha = mix(1, orbitAlpha, fly) * dotOpacity; + const target = paletteRgb[i % paletteRgb.length]; + const cr = Math.round(mix(logoRgb[0], target[0], colorAmount)); + const cg = Math.round(mix(logoRgb[1], target[1], colorAmount)); + const cb = Math.round(mix(logoRgb[2], target[2], colorAmount)); + + dots.push({ x, y, r, z: depth * fly, fill: `rgba(${cr},${cg},${cb},${alpha})` }); + } + + dots.sort((a, b) => a.z - b.z); + for (const d of dots) { + ctx.fillStyle = d.fill; + ctx.beginPath(); + ctx.arc(d.x, d.y, Math.max(0.3, d.r), 0, Math.PI * 2); + ctx.fill(); + } +} + +export type AgentLogoMorphProps = { + size?: number; + active?: boolean; + activation?: number; + colors?: string[]; + /** Colour the dots are born as during the handoff (matches the logo green). */ + logoColor?: string; + dotCount?: number; + orbitCount?: number; + particlesPerOrbit?: number; + speed?: number; + transitionMs?: number; + className?: string; + style?: CSSProperties; + "aria-label"?: string; +}; + +export function AgentLogoMorph({ + size = 20, + active = false, + activation, + colors = AGENT_ORB_PALETTE, + logoColor = "#41ff54", + dotCount = 21, + orbitCount = 3, + particlesPerOrbit = 3, + speed = 3.9, + transitionMs = 600, + className, + style, + "aria-label": ariaLabel, +}: AgentLogoMorphProps) { + const canvasRef = useRef(null); + const logoRef = useRef(null); + + const activeRef = useRef(active); + const controlledRef = useRef(activation); + const amountRef = useRef(activation ?? (active ? 1 : 0)); + const timeRef = useRef(0); + const wakeRef = useRef<() => void>(() => {}); + + const colorsKey = colors.join(","); + + useEffect(() => { + activeRef.current = active; + controlledRef.current = activation; + wakeRef.current(); + }, [active, activation]); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + + const dpr = Math.min(2, typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1); + canvas.width = Math.round(size * dpr); + canvas.height = Math.round(size * dpr); + + const cfg: LogoMorphConfig = { + size, + outline: logoOutlinePoints(dotCount), + dotSpecs: buildDotSpecs(dotCount, orbitCount, particlesPerOrbit), + orbitGeoms: buildOrbitGeoms(orbitCount), + paletteRgb: colors.map(hexToRgb), + logoRgb: hexToRgb(logoColor), + }; + + const setLogo = (a: number) => { + const el = logoRef.current; + if (!el) return; + const hand = smoothstep(0, 0.3, a); + el.style.opacity = String(1 - hand); + el.style.transform = `scale(${1 - 0.06 * hand})`; + el.style.visibility = hand >= 0.999 ? "hidden" : "visible"; + }; + + const reduced = + typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduced) { + setLogo(0); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, size, size); + return; + } + + let raf = 0; + let running = false; + let last = 0; + + const frame = (now: number) => { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + + const controlled = controlledRef.current; + let keepGoing: boolean; + if (controlled != null) { + amountRef.current = controlled; + keepGoing = controlled > 0; + } else { + const target = activeRef.current ? 1 : 0; + const step = transitionMs > 0 ? (dt * 1000) / transitionMs : 1; + if (amountRef.current < target) { + amountRef.current = Math.min(target, amountRef.current + step); + } else if (amountRef.current > target) { + amountRef.current = Math.max(target, amountRef.current - step); + } + keepGoing = amountRef.current !== target || target === 1; + } + + const a = amountRef.current; + if (a > 0) { + timeRef.current += dt * speed; + } + setLogo(a); + drawLogoMorphFrame(ctx, dpr, a, timeRef.current, cfg); + + if (keepGoing) { + raf = requestAnimationFrame(frame); + } else { + running = false; + } + }; + + const wake = () => { + if (running) return; + running = true; + last = typeof performance !== "undefined" ? performance.now() : 0; + raf = requestAnimationFrame(frame); + }; + wakeRef.current = wake; + + setLogo(amountRef.current); + drawLogoMorphFrame(ctx, dpr, amountRef.current, timeRef.current, cfg); + wake(); + + return () => { + running = false; + wakeRef.current = () => {}; + cancelAnimationFrame(raf); + }; + }, [size, speed, transitionMs, colorsKey, logoColor, dotCount, orbitCount, particlesPerOrbit]); + + return ( + +
+ +
+ +
+ ); +} diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx new file mode 100644 index 00000000000..54407f2cb46 --- /dev/null +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -0,0 +1,548 @@ +import { useEffect, useRef, useState } from "react"; +import { Button, type ButtonVariant } from "~/components/primitives/Buttons"; +import { cn } from "~/utils/cn"; +import { + ClientTabs, + ClientTabsContent, + ClientTabsList, + ClientTabsTrigger, +} from "~/components/primitives/ClientTabs"; +import { Header1, Header2 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + AgentDotMatrix, + DOT_MATRIX_PALETTES, + DOT_SHAPES, + EXTRA_FACE_SHAPES, + FACE_SHAPES, + type DotMatrixPaletteName, + type DotShapeName, +} from "./AgentDotMatrix"; +import { AgentLogoMorph, AgentOrb, type AgentOrbProps } from "./AgentOrb"; + +// Experiments for the trigger.dev AI dashboard-agent identity: a resting logo +// that animates while the agent thinks. Each tab is a separate experiment. + +export default function Story() { + return ( +
+
+ AI agent + + A resting logo that animates while the agent thinks. Each tab is a separate experiment. + +
+ + + + Dot matrix + + + Logo morph + + + Orbit dots + + + Logo cloud + + + + + + + + + + + + + + + +
+ ); +} + +// --- Dot matrix (5x5) --------------------------------------------------------- + +// Dark-ink mono ramp for light surfaces (the built-in mono palette is white-based). +const LIGHT_MONO = { + stops: ["#0d0e12", "#1a1b1f", "#3b3e45"] as [string, string, string], + glow: "#1a1b1f", +}; + +function DotMatrixTab() { + return ( +
+ + 5×5 grid. A bright head walks each shape's route on a fixed beat, 2 cycles per shape, + handing off at shared dots. Click to toggle. + +
+ {[16, 20, 24, 32, 48].map((s) => ( +
+
+ +
+
{s}px rest
+
+ ))} +
+ + The ask-ai Button variant. Mono logo, click to + think for 5s. + +
+ {( + [ + ["ask-ai/small", 16, "small"], + ["ask-ai/medium", 16, "medium"], + ["ask-ai/large", 20, "large"], + ] as [ButtonVariant, number, string][] + ).map(([variant, matrixSize, label]) => ( +
+ +
{label}
+
+ ))} +
+ + Small-button icon size comparison (crispness): + +
+ {[14, 15, 16].map((s) => ( +
+ +
{s}px icon
+
+ ))} +
+
+ {[20, 32, 48, 72].map((s) => ( +
+ +
+ {s}px · click +
+
+ ))} +
+ + Mono on dark (charcoal-800) and light (charcoal-100): + +
+
+ + +
+
+ + +
+
+ + Face suite (small buttons, click to think): + +
+ {EXTRA_FACE_SHAPES.map((name) => ( + + ))} +
+ + Face options (grid always visible here): + +
+ {FACE_SHAPES.map((name) => ( +
+ +
{name}
+
+ ))} +
+ + Palettes (always running): + +
+ {(Object.keys(DOT_MATRIX_PALETTES) as DotMatrixPaletteName[]).map((name) => ( +
+ +
{name}
+
+ ))} +
+ + Shape library (5-line string bitmaps): + +
+ {(Object.keys(DOT_SHAPES) as DotShapeName[]).map((name) => ( +
+ +
{name}
+
+ ))} +
+
+ ); +} + +function AskOttoButton({ variant, matrixSize }: { variant: ButtonVariant; matrixSize: number }) { + const [active, setActive] = useState(false); + const timeout = useRef>(); + + useEffect(() => () => clearTimeout(timeout.current), []); + + const trigger = () => { + setActive(true); + clearTimeout(timeout.current); + timeout.current = setTimeout(() => setActive(false), 5000); + }; + + return ( + + ); +} + +function FaceButton({ name }: { name: DotShapeName }) { + const [active, setActive] = useState(false); + const timeout = useRef>(); + + useEffect(() => () => clearTimeout(timeout.current), []); + + const trigger = () => { + setActive(true); + clearTimeout(timeout.current); + timeout.current = setTimeout(() => setActive(false), 5000); + }; + + return ( + + ); +} + +function ToggleableMatrix({ + size, + matrix, +}: { + size: number; + matrix?: Partial>; +}) { + const [active, setActive] = useState(false); + + return ( + + ); +} + +// --- Logo morph (crisp logo -> orbits) ----------------------------------------- + +function LogoMorphTab() { + return ( +
+ + Crisp logo at rest; dots born on its outline fly out to orbits. Click or scrub. + +
+ {[16, 20, 24, 32, 48].map((s) => ( +
+
+ +
+
{s}px rest
+
+ ))} +
+
+ {[20, 32, 48, 72].map((s) => ( +
+ +
+ {s}px · click +
+
+ ))} +
+ +
+ ); +} + +function TriggerableLogoMorph({ size }: { size: number }) { + const [active, setActive] = useState(false); + const timeout = useRef>(); + + useEffect(() => () => clearTimeout(timeout.current), []); + + const trigger = () => { + setActive(true); + clearTimeout(timeout.current); + timeout.current = setTimeout(() => setActive(false), 4000); + }; + + return ( + + ); +} + +function LogoMorphScrubber({ size = 140 }: { size?: number }) { + const [value, setValue] = useState(0); + + return ( +
+ +
+ {value.toFixed(2)} + setValue(parseFloat(e.target.value))} + aria-label="Morph amount" + className="h-1 w-full cursor-pointer appearance-none rounded-full bg-grid-bright accent-blue-500" + /> +
+
+ Logo + Working +
+
+ ); +} + +// --- Orbit dots (triangle -> tilted 3D orbits) ---------------------------------- + +function OrbitDotsTab() { + return ( +
+ + Dotted triangle ↔ tilted 3D orbits. White at rest, palette while working. + +
+ Static logo +
+ {[16, 20, 24, 32, 48].map((s) => ( +
+
+ +
+
{s}px
+
+ ))} +
+
+ + Ask the agent +
+
+ + Ask the agent +
+
+
+
+
+ Click to think +
+ {[20, 32, 48, 72].map((s) => ( +
+ +
{s}px
+
+ ))} +
+
+
+ Variant — white particles +
+ {[20, 32, 48, 72].map((s) => ( +
+ +
{s}px
+
+ ))} +
+
+
+ Morph scrubber + +
+
+ ); +} + +// --- Logo cloud (600-dot logo silhouette) --------------------------------------- + +const LOGO_CLOUD: Partial = { + restShape: "logo", + dotCount: 600, + orbitCount: 12, + particlesPerOrbit: 4, +}; + +function LogoCloudTab() { + return ( +
+ + 600 dots in the logo silhouette; morphs into a working cloud. + +
+ {[16, 20, 24, 32, 48, 96].map((s) => ( +
+
+ +
+
{s}px
+
+ ))} +
+
+ {[24, 48, 96].map((s) => ( +
+ +
+ {s}px · click +
+
+ ))} +
+ +
+ ); +} + +// --- shared helpers ------------------------------------------------------------- + +function TriggerableOrb({ size, orb }: { size: number; orb?: Partial }) { + const [active, setActive] = useState(false); + const timeout = useRef>(); + + useEffect(() => () => clearTimeout(timeout.current), []); + + const trigger = () => { + setActive(true); + clearTimeout(timeout.current); + timeout.current = setTimeout(() => setActive(false), 4000); + }; + + return ( + + ); +} + +function Scrubber({ + size = 140, + sizes, + orb, +}: { + size?: number; + sizes?: number[]; + orb?: Partial; +}) { + const [value, setValue] = useState(0); + const sizeList = sizes ?? [size]; + + return ( +
+
+ {sizeList.map((s) => ( +
+ +
{s}px
+
+ ))} +
+
+ {value.toFixed(2)} + setValue(parseFloat(e.target.value))} + aria-label="Morph amount" + className="h-1 w-full cursor-pointer appearance-none rounded-full bg-grid-bright accent-blue-500" + /> +
+
+ Triangle + Working +
+
+ ); +} + +// The trigger.dev brand triangle, but with a solid `currentColor` fill instead of +// LogoIcon's green→yellow gradient (so it can be coloured via a text-* class). +function TriggerTriangle({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/routes/storybook.buttons/route.tsx b/apps/webapp/app/routes/storybook.buttons/route.tsx index 29f4a4b078a..046f00b2ed2 100644 --- a/apps/webapp/app/routes/storybook.buttons/route.tsx +++ b/apps/webapp/app/routes/storybook.buttons/route.tsx @@ -21,6 +21,7 @@ import { import { Button } from "~/components/primitives/Buttons"; import { Header1, Header3 } from "~/components/primitives/Headers"; import { Spinner } from "~/components/primitives/Spinner"; +import { AgentDotMatrix } from "../storybook.ai-agent/AgentDotMatrix"; export default function Story() { const isSelected = true; @@ -387,6 +388,42 @@ export default function Story() { + Ask AI button +
+
+ Sizes + + + +
+
+ Disabled + +
+
+ Reserved for the AI agent. See the "AI agent" story for the live pairing. +
+
); } diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 012d47827de..97ab8a35963 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -7,6 +7,10 @@ import { requireUser } from "~/services/session.server"; import { cn } from "~/utils/cn"; const stories: Story[] = [ + { + name: "AI agent", + slug: "ai-agent", + }, { name: "Animated panel", slug: "animated-panel",