From 04a4b082376b4814348c97b5cb0a6fdbf16928f2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Jul 2026 18:53:23 +0100 Subject: [PATCH 01/12] feat(webapp): AI agent storybook experiments and ask-ai button variant Admin-only storybook page exploring an animated agent identity: a resting dot logo that animates while the agent thinks. Native canvas, no dependencies. Adds a reserved ask-ai Button variant. --- .../app/components/primitives/Buttons.tsx | 21 + .../storybook.ai-agent/AgentDotMatrix.tsx | 560 +++++++++++++ .../routes/storybook.ai-agent/AgentOrb.tsx | 767 ++++++++++++++++++ .../app/routes/storybook.ai-agent/route.tsx | 511 ++++++++++++ .../app/routes/storybook.buttons/route.tsx | 37 + apps/webapp/app/routes/storybook/route.tsx | 4 + 6 files changed, 1900 insertions(+) create mode 100644 apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx create mode 100644 apps/webapp/app/routes/storybook.ai-agent/AgentOrb.tsx create mode 100644 apps/webapp/app/routes/storybook.ai-agent/route.tsx diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 323f374382..4883e97c0c 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 0000000000..6f9d7a572d --- /dev/null +++ b/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx @@ -0,0 +1,560 @@ +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"], + // 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"]; + +// 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. + const devCenter = (cell: number) => Math.round((cell + 0.5) * pitch * dpr); + const devDotR = 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 0000000000..82407550f5 --- /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 0000000000..7290d1a384 --- /dev/null +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -0,0 +1,511 @@ +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, + 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 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 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 29f4a4b078..046f00b2ed 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 012d47827d..97ab8a3596 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", From 986b12883b13a3fd65a66ffa355dd6adbdbee8d7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 28 Jul 2026 09:13:22 +0100 Subject: [PATCH 02/12] feat(webapp): 20 more agent faces, small-button suite, light-mode dot sizing Adds 20 named face bitmaps shown as a suite of small ask-ai buttons, and compensates for dark-on-light dots reading optically smaller by boosting dot radius in light mode. --- .../storybook.ai-agent/AgentDotMatrix.tsx | 51 ++++++++++++++++++- .../app/routes/storybook.ai-agent/route.tsx | 37 ++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx b/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx index 6f9d7a572d..e32b5e1bde 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx @@ -36,6 +36,27 @@ export const DOT_SHAPES = { 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", "....."], @@ -50,6 +71,29 @@ 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[] = [ @@ -376,8 +420,13 @@ export function AgentDotMatrix({ // 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 = Math.max(1, Math.round(dotR * 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); diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index 7290d1a384..54407f2cb4 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -13,6 +13,7 @@ import { AgentDotMatrix, DOT_MATRIX_PALETTES, DOT_SHAPES, + EXTRA_FACE_SHAPES, FACE_SHAPES, type DotMatrixPaletteName, type DotShapeName, @@ -146,6 +147,14 @@ function DotMatrixTab() { /> + + Face suite (small buttons, click to think): + +
+ {EXTRA_FACE_SHAPES.map((name) => ( + + ))} +
Face options (grid always visible here): @@ -210,6 +219,34 @@ function AskOttoButton({ variant, matrixSize }: { variant: ButtonVariant; matrix ); } +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, From 0f35977d432ef4a399e0183caeb12f162e2f71bc Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 28 Jul 2026 12:58:48 +0100 Subject: [PATCH 03/12] style(webapp): tighten caption spacing in AI agent storybook --- .../app/routes/storybook.ai-agent/route.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index 54407f2cb4..a389fa53bb 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -75,7 +75,7 @@ const LIGHT_MONO = { 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. @@ -89,7 +89,7 @@ function DotMatrixTab() {
))} - + The ask-ai Button variant. Mono logo, click to think for 5s. @@ -107,7 +107,7 @@ function DotMatrixTab() { ))} - + Small-button icon size comparison (crispness):
@@ -128,7 +128,7 @@ function DotMatrixTab() {
))} - + Mono on dark (charcoal-800) and light (charcoal-100):
@@ -147,7 +147,7 @@ function DotMatrixTab() { />
- + Face suite (small buttons, click to think):
@@ -155,7 +155,7 @@ function DotMatrixTab() { ))}
- + Face options (grid always visible here):
@@ -166,7 +166,7 @@ function DotMatrixTab() {
))} - + Palettes (always running):
@@ -177,7 +177,7 @@ function DotMatrixTab() {
))} - + Shape library (5-line string bitmaps):
@@ -275,7 +275,7 @@ function ToggleableMatrix({ function LogoMorphTab() { return (
- + Crisp logo at rest; dots born on its outline fly out to orbits. Click or scrub.
@@ -360,7 +360,7 @@ function LogoMorphScrubber({ size = 140 }: { size?: number }) { function OrbitDotsTab() { return (
- + Dotted triangle ↔ tilted 3D orbits. White at rest, palette while working.
@@ -428,7 +428,7 @@ const LOGO_CLOUD: Partial = { function LogoCloudTab() { return (
- + 600 dots in the logo silhouette; morphs into a working cloud.
From dd4cd072e339065f611caecb41fa13d0fda4a18e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 30 Jul 2026 13:59:24 +0100 Subject: [PATCH 04/12] chore(webapp): trigger preview rebuild From 87b4599f2555e11142a0b14b9ee62d0127ac419c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 31 Jul 2026 20:06:04 +0100 Subject: [PATCH 05/12] fix(webapp): address review feedback on agent storybook Clamp animation speed so a non-positive value cannot spin the step loop. Cancel a pending return-to-rest when thinking is re-enabled mid-settle. Mark in-button logos decorative so they do not pollute the button accessible name. Size the scratch canvas used for logo hit-testing. --- .../storybook.ai-agent/AgentDotMatrix.tsx | 30 +++++++++++++++---- .../routes/storybook.ai-agent/AgentOrb.tsx | 7 ++++- .../app/routes/storybook.ai-agent/route.tsx | 9 +++++- .../app/routes/storybook.buttons/route.tsx | 8 ++--- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx b/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx index e32b5e1bde..01edaa160e 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx @@ -245,6 +245,11 @@ export type AgentDotMatrixProps = { gridAtRest?: boolean; /** Animation speed multiplier. */ speed?: number; + /** + * Render as a decorative glyph (aria-hidden, no role) — use when the logo + * sits beside a text label that already names the control. + */ + decorative?: boolean; className?: string; style?: CSSProperties; "aria-label"?: string; @@ -267,6 +272,7 @@ export function AgentDotMatrix({ gridOpacity = 0.18, gridAtRest = false, speed = 1, + decorative = false, className, style, "aria-label": ariaLabel, @@ -311,8 +317,11 @@ export function AgentDotMatrix({ const pitch = size / MATRIX; const dotR = Math.max(0.75, pitch * 0.3); - const stepMs = STEP_MS / speed; - const blendMs = BLEND_MS / speed; + // Clamp: a zero or negative speed would make stepMs non-positive and spin + // the step loop forever. + const safeSpeed = Math.max(0.01, speed); + const stepMs = STEP_MS / safeSpeed; + const blendMs = BLEND_MS / safeSpeed; // 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]; @@ -496,8 +505,16 @@ export function AgentDotMatrix({ if (!activeRef.current) { seeking = "rest"; - } else if (seeking !== "next" && stepsInShape >= cyclesPerShape * route.length) { - seeking = "next"; + } else { + if (seeking === "rest") { + // Re-activated mid-settle: cancel the pending return to rest so a + // quick off/on toggle carries on instead of restarting. + seeking = null; + seekSteps = 0; + } + if (seeking !== "next" && stepsInShape >= cyclesPerShape * route.length) { + seeking = "next"; + } } if (!seeking) return; @@ -600,8 +617,9 @@ export function AgentDotMatrix({ return ( diff --git a/apps/webapp/app/routes/storybook.ai-agent/AgentOrb.tsx b/apps/webapp/app/routes/storybook.ai-agent/AgentOrb.tsx index 82407550f5..f83651987e 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/AgentOrb.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/AgentOrb.tsx @@ -153,7 +153,12 @@ function logoCloud(count: number): Pt[] { const cached = logoCloudCache.get(count); if (cached) return cached; - const ctx = document.createElement("canvas").getContext("2d"); + // Size the scratch canvas to the logo box: isPointInPath is specified against + // the path region, not the bitmap, but this removes any engine-specific doubt. + const scratch = document.createElement("canvas"); + scratch.width = LOGO_W; + scratch.height = LOGO_H; + const ctx = scratch.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 diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index a389fa53bb..d840190010 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -212,7 +212,13 @@ function AskOttoButton({ variant, matrixSize }: { variant: ButtonVariant; matrix variant === "ask-ai/small" ? "gap-x-1.5" : "gap-x-2" )} > - + Ask Otto @@ -240,6 +246,7 @@ function FaceButton({ name }: { name: DotShapeName }) { restShape={name} palette="mono" restColor="#ffffff" + decorative /> {name} diff --git a/apps/webapp/app/routes/storybook.buttons/route.tsx b/apps/webapp/app/routes/storybook.buttons/route.tsx index fe4a92253f..e21c43024d 100644 --- a/apps/webapp/app/routes/storybook.buttons/route.tsx +++ b/apps/webapp/app/routes/storybook.buttons/route.tsx @@ -406,19 +406,19 @@ export default function Story() { Sizes @@ -427,7 +427,7 @@ export default function Story() { Disabled From 9f3f19598cf9e35d43444f713ee36ea635e1e710 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 31 Jul 2026 20:13:05 +0100 Subject: [PATCH 06/12] fix(webapp): reset the seek budget when the agent logo changes seek mode Switching from the next-shape search to the settle-to-rest search reused the spent step budget, so the fallback could fire immediately and snap the logo back from an arbitrary dot instead of one shared with the rest shape. --- .../app/routes/storybook.ai-agent/AgentDotMatrix.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx b/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx index 01edaa160e..0cf9ae53f9 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx @@ -503,8 +503,13 @@ export function AgentDotMatrix({ history.unshift(route[headPos]); if (history.length > SNAKE_TAIL.length) history.length = SNAKE_TAIL.length; + // Every change of seek mode restarts the step budget, so each search gets + // a full lap to find a shared dot before its fallback fires. if (!activeRef.current) { - seeking = "rest"; + if (seeking !== "rest") { + seeking = "rest"; + seekSteps = 0; + } } else { if (seeking === "rest") { // Re-activated mid-settle: cancel the pending return to rest so a @@ -514,6 +519,7 @@ export function AgentDotMatrix({ } if (seeking !== "next" && stepsInShape >= cyclesPerShape * route.length) { seeking = "next"; + seekSteps = 0; } } if (!seeking) return; From 66d31c9d354c657b60594255c9dbd0ba6231b8d9 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 31 Jul 2026 20:58:00 +0100 Subject: [PATCH 07/12] feat(webapp): ask-ai buttons supply the agent logo automatically The ask-ai variants now provide the static agent logo as their leading icon, so callers just write . Passing a LeadingIcon still overrides it, which is how the animated state is driven. Moves AgentDotMatrix into primitives so the shared Button does not import from a storybook route. --- .../primitives}/AgentDotMatrix.tsx | 0 .../app/components/primitives/Buttons.tsx | 36 +++++++++++++------ .../app/routes/storybook.ai-agent/route.tsx | 33 +++++++++-------- .../app/routes/storybook.buttons/route.tsx | 31 ++++------------ 4 files changed, 51 insertions(+), 49 deletions(-) rename apps/webapp/app/{routes/storybook.ai-agent => components/primitives}/AgentDotMatrix.tsx (100%) diff --git a/apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx similarity index 100% rename from apps/webapp/app/routes/storybook.ai-agent/AgentDotMatrix.tsx rename to apps/webapp/app/components/primitives/AgentDotMatrix.tsx diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 96b57667c4..4b78f359ce 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -9,6 +9,7 @@ import React, { } from "react"; import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; import { cn } from "~/utils/cn"; +import { AgentDotMatrix } from "./AgentDotMatrix"; import { ShortcutKey } from "./ShortcutKey"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip"; import { Icon, type RenderIcon } from "./Icon"; @@ -135,15 +136,25 @@ function createVariant(sizeName: Size, themeName: Theme) { iconSpacing: sizes[sizeName].iconSpacing, shortcutVariant: sizes[sizeName].shortcutVariant, shortcut: cn(sizes[sizeName].shortcut, theme[themeName].shortcut), + // Rendered as the leading icon when the caller doesn't pass one. + defaultLeadingIcon: undefined as RenderIcon | undefined, }; } -// 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) { +// The ask-ai button always leads with the square agent logo, so it supplies its +// own leading icon and its padding is tuned around it: small = 16px logo, 4px +// left / 6px right; medium 32/16 -> 8px; large 40/20 -> 10px. Pass an explicit +// `LeadingIcon` (e.g. an ) to animate it. +function createAskAiVariant(sizeName: Size, opticalPadding: string, logoSize: number) { const base = createVariant(sizeName, "ask-ai"); - return { ...base, button: cn(base.button, opticalPadding) }; + return { + ...base, + button: cn(base.button, opticalPadding), + iconSpacing: "gap-x-1.5", + defaultLeadingIcon: ( + + ), + }; } const variant = { @@ -176,9 +187,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"), + "ask-ai/small": createAskAiVariant("small", "px-1 pr-1.5", 16), + "ask-ai/medium": createAskAiVariant("medium", "px-2", 16), + "ask-ai/large": createAskAiVariant("large", "px-2.5", 20), "menu-item": { textColor: "text-text-bright px-1", button: @@ -187,6 +198,7 @@ const variant = { iconSpacing: "gap-x-0.5", shortcutVariant: undefined, shortcut: undefined, + defaultLeadingIcon: undefined, }, "small-menu-item": { textColor: "text-text-bright", @@ -196,6 +208,7 @@ const variant = { iconSpacing: "gap-x-1.5", shortcutVariant: undefined, shortcut: undefined, + defaultLeadingIcon: undefined, }, "small-menu-sub-item": { textColor: "text-text-dimmed", @@ -205,6 +218,7 @@ const variant = { iconSpacing: undefined, shortcutVariant: undefined, shortcut: undefined, + defaultLeadingIcon: undefined, }, }; @@ -261,6 +275,8 @@ export function ButtonContent(props: ButtonContentPropsType) { }, [isLoading]); const variation = allVariants.variant[props.variant]; + // Some variants (ask-ai) always lead with their own glyph unless overridden. + const leadingIcon = LeadingIcon ?? variation.defaultLeadingIcon; const btnClassName = cn(allVariants.$all, variation.button); const iconClassName = variation.icon; @@ -290,9 +306,9 @@ export function ButtonContent(props: ButtonContentPropsType) { showSpinner && "invisible" )} > - {LeadingIcon && ( + {leadingIcon && ( setActive(false), 5000); }; + // Overrides the variant's built-in static logo so it can animate. return ( - ); } @@ -238,8 +237,11 @@ function FaceButton({ name }: { name: DotShapeName }) { }; return ( - ); } diff --git a/apps/webapp/app/routes/storybook.buttons/route.tsx b/apps/webapp/app/routes/storybook.buttons/route.tsx index e21c43024d..17d3a7280d 100644 --- a/apps/webapp/app/routes/storybook.buttons/route.tsx +++ b/apps/webapp/app/routes/storybook.buttons/route.tsx @@ -21,7 +21,6 @@ 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; @@ -404,36 +403,20 @@ export default function Story() {
Sizes - - - + + +
Disabled
- Reserved for the AI agent. See the "AI agent" story for the live pairing. + Reserved for the AI agent. The variant supplies the agent logo automatically, so no + LeadingIcon is needed. Pass one to animate it. See the "AI agent" story for the live + pairing.
From afaf7c93f63d633db79103532d29e0bfaab62393 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 31 Jul 2026 21:18:39 +0100 Subject: [PATCH 08/12] feat(webapp): use the alien face as the default agent logo The former demon bitmap takes over the alien name and becomes the resting logo everywhere the variant supplies it. The previous alien bitmap is renamed sprite. --- apps/webapp/app/components/primitives/AgentDotMatrix.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/primitives/AgentDotMatrix.tsx b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx index 0cf9ae53f9..dd4484c29b 100644 --- a/apps/webapp/app/components/primitives/AgentDotMatrix.tsx +++ b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx @@ -33,7 +33,7 @@ export const DOT_SHAPES = { 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.."], + alien: ["o...o", ".ooo.", "ooooo", "o.o.o", ".ooo."], robot: ["ooooo", "o.o.o", "ooooo", "o...o", "ooooo"], ghost: [".ooo.", "ooooo", "o.o.o", "ooooo", "o.o.o"], // Extended face set. @@ -52,7 +52,7 @@ export const DOT_SHAPES = { 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."], + sprite: [".ooo.", "ooooo", "o.o.o", ".ooo.", "..o.."], 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."], @@ -87,7 +87,7 @@ export const EXTRA_FACE_SHAPES: DotShapeName[] = [ "octopus", "skull", "cyclops", - "demon", + "sprite", "clown", "mech", "angel", @@ -261,7 +261,7 @@ const BLEND_MS = 240; export function AgentDotMatrix({ size = 20, active = false, - restShape = "ghost", + restShape = "alien", playlist = DEFAULT_PLAYLIST, cyclesPerShape = 2, palette = "trigger", From fa75f042e8ef061e77eeea68360245174e21fdbd Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 31 Jul 2026 21:21:51 +0100 Subject: [PATCH 09/12] chore(webapp): label the agent storybook buttons Ask AI --- apps/webapp/app/routes/storybook.ai-agent/route.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index a7d66ea3b6..098b28a5c8 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -101,7 +101,7 @@ function DotMatrixTab() { ] as [ButtonVariant, number, string][] ).map(([variant, matrixSize, label]) => (
- +
{label}
))} @@ -112,7 +112,7 @@ function DotMatrixTab() {
{[14, 15, 16].map((s) => (
- +
{s}px icon
))} @@ -191,7 +191,7 @@ function DotMatrixTab() { ); } -function AskOttoButton({ variant, matrixSize }: { variant: ButtonVariant; matrixSize: number }) { +function AskAiButton({ variant, matrixSize }: { variant: ButtonVariant; matrixSize: number }) { const [active, setActive] = useState(false); const timeout = useRef>(); @@ -219,7 +219,7 @@ function AskOttoButton({ variant, matrixSize }: { variant: ButtonVariant; matrix /> } > - Ask Otto + Ask AI ); } From df152f1380b14c4295c22f080fc40b838b7e898f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 31 Jul 2026 21:29:31 +0100 Subject: [PATCH 10/12] fix(webapp): give ask-ai buttons a pointer cursor from the variant Moves the pointer cursor onto the ask-ai theme so every use gets it, instead of each call site passing a className, and reverts to the default cursor when disabled. --- apps/webapp/app/components/primitives/Buttons.tsx | 2 +- apps/webapp/app/routes/storybook.ai-agent/route.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 4b78f359ce..792f73eb59 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -119,7 +119,7 @@ const theme = { "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", + "cursor-pointer 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:cursor-default 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", diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index 098b28a5c8..79ebd6af56 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -208,7 +208,6 @@ function AskAiButton({ variant, matrixSize }: { variant: ButtonVariant; matrixSi
); @@ -424,46 +418,6 @@ function OrbitDotsTab() { ); } -// --- 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 }) { @@ -554,3 +508,741 @@ function TriggerTriangle({ className }: { className?: string }) { ); } + +// ============================================================================ +// Orbit-dots / logo-morph experiment (inlined: contained to this storybook page) +// ============================================================================ + +// 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). +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; +} + +// 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 ------------------------------------------------------------ + +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"; + /** 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; +}; + +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(() => 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(); + } +} + +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; +}; + +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 ( + +
+ +
+ +
+ ); +} From e8f027f06dcbedd3420caed10fbf515efc757b75 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 31 Jul 2026 21:48:48 +0100 Subject: [PATCH 12/12] fix(webapp): honour a controlled morph amount under reduced motion The orb and logo morph painted a single resting frame and never repainted, so with reduced motion enabled the scrubber looked dead. They now render the controlled amount and repaint when it changes, while still refusing to run the animation loop. --- .../app/routes/storybook.ai-agent/route.tsx | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index 32cf9c6089..76605c3346 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -878,8 +878,18 @@ function AgentOrb({ typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduced) { - drawFrame(ctx, dpr, 0, 0, cfg); - return; + // No animation, but a controlled amount (the scrubber) is a static pose, + // so still honour it and repaint when it changes. + const paint = () => { + const amount = controlledRef.current ?? (activeRef.current ? 1 : 0); + amountRef.current = amount; + drawFrame(ctx, dpr, amount, 0, cfg); + }; + wakeRef.current = paint; + paint(); + return () => { + wakeRef.current = () => {}; + }; } let raf = 0; @@ -1157,10 +1167,19 @@ function AgentLogoMorph({ 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; + // Same as the orb: freeze the animation, but still render a controlled + // amount so the scrubber keeps working. + const paint = () => { + const amount = controlledRef.current ?? (activeRef.current ? 1 : 0); + amountRef.current = amount; + setLogo(amount); + drawLogoMorphFrame(ctx, dpr, amount, 0, cfg); + }; + wakeRef.current = paint; + paint(); + return () => { + wakeRef.current = () => {}; + }; } let raf = 0;