diff --git a/.claude/skills/openbot-screen-layout/SKILL.md b/.claude/skills/openbot-screen-layout/SKILL.md
index 20cd104d6f..39442b1160 100644
--- a/.claude/skills/openbot-screen-layout/SKILL.md
+++ b/.claude/skills/openbot-screen-layout/SKILL.md
@@ -188,11 +188,11 @@ itself when the dialog changes something.
## Units
-The root is the browser's 16px, so Tailwind's spacing, sizes and widths are their documented values
-(`p-4` is 16px, `max-w-3xl` is 768px) and land on its 4px grid. Text is the exception: `styles.css`
-sets every `--text-*` to Tailwind's default times 15/16, so `text-sm` is 13.125px and body text is
-15px. That is the scale this app has always read at; do not size text in px or rem by hand to match
-the docs.
+The root is the browser's 16px, so Tailwind's spacing, sizes, widths and text sizes are their
+documented values (`p-4` is 16px, `max-w-3xl` is 768px, `text-sm` is 14px) and land on its 4px grid.
+Text used to be the exception, on a 15px scale of its own; it is not any more. Where a screen needs one
+of the named sizes, use the five `type-*` utilities in `theme.css`; do not size text in px or rem by
+hand.
Even so, never hardcode a pixel constant derived from a Tailwind token, and never compute a ratio
from one. Measure the element — `offsetWidth`, `clientWidth`, a `ResizeObserver` — and scale from what
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27745c6f84..8b5de0ad9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -133,6 +133,36 @@ again when they come back to the end or press the arrow. A rail beside a convers
per turn: hovering one previews what was asked and answered, and choosing it goes there. Nothing
changes for a deployment.
+### First run shows what OpenBot does, one step at a time
+
+- The looping intro is replaced by steps that show what OpenBot does (a browser and files of its
+ own, a team of Bots, and asking before it acts), then Connect your apps, Get notified, Check your
+ setup, and the name step, whose Get started opens the app. On a desktop the steps are listed on the
+ left beside a picture of each; on a phone they come one at a time. A reload, browser Back and a
+ connection's return all keep the person's place, which is now saved as they go.
+- Check your setup asks the new `GET /api/setup` whether Intelligence, the model key, the Bot computer,
+ more apps and Slack and Teams work, row by row. An admin, or somebody running OpenBot alone, is
+ shown the page or the variables that close a gap; everybody else only that their admin sets it up.
+ Check again asks Intelligence and the model provider again, at most every 10 seconds. Reading the
+ checklist does the same once an answer has failed or is five minutes old, without waiting for it,
+ so the next read has the new answer. To check the Bot computer, the server sends each running
+ computer its network policy whenever it asks, and sees that it answers; it wakes none.
+- An app connected from Connect your apps comes back to that step from the app's own sign-in page,
+ whether it connected, failed or was cancelled there. A connection started from Connections or an
+ app's admin page comes back where it did before.
+
+### OpenBot has a new look
+
+- Every screen has new colours: cool neutral greys, a near-black primary button that turns light
+ in dark mode, a purple focus ring, slightly rounder corners and quicker easing. Text now follows
+ the browser's own size, so it is a little larger than before.
+- Dark mode follows the system by default. Settings → General → Appearance offers System, Light and
+ Dark, kept in this browser. Someone who had turned the old dark switch on stays dark.
+- Every Bot, and OpenBot's own mark, is an orb with eyes that blinks and follows the pointer. It
+ holds still with reduced motion.
+- Each Bot's orb takes its colour and pattern from the Bot, so two Bots in one colour still look
+ different. Violet is kept for OpenBot's own mark.
+
### Generated interfaces use A2UI 1.0
With generative UI on, a Bot builds an interface from the A2UI 1.0 basic components instead of the
diff --git a/app/index.html b/app/index.html
index a839d57c50..b35c822cb2 100644
--- a/app/index.html
+++ b/app/index.html
@@ -5,15 +5,27 @@
OpenBot
+
diff --git a/app/package.json b/app/package.json
index b5be1f7ac8..406d4f0a40 100644
--- a/app/package.json
+++ b/app/package.json
@@ -28,7 +28,6 @@
"@tanstack/react-router": "^1.170.27",
"@tanstack/react-table": "^9.3.1",
"better-auth": "^1.6.27",
- "boring-avatars": "^2.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx
index 9ca9845070..d746511206 100644
--- a/app/src/components/agents/abstract-avatar.tsx
+++ b/app/src/components/agents/abstract-avatar.tsx
@@ -1,5 +1,14 @@
-import Avatar from "boring-avatars";
+import { BotOrb } from "@/components/agents/orb/bot-orb";
+/**
+ * A Bot's face: its orb, with the look its seed picks. The seed is the Bot's id unless a tenant
+ * package set one or the Bot was copied from another, so `ChannelAvatar`, which goes by id, matches it
+ * for every other Bot.
+ * The orb hides itself from assistive tech, so the Bot is announced once, by name.
+ *
+ * Clipped to its circle, so its glow never spills into the row beside it or makes a list scroll.
+ * At rest the glow is too faint that far out to be missed.
+ */
export function AbstractAvatar({
name,
seed,
@@ -16,10 +25,7 @@ export function AbstractAvatar({
className="inline-flex shrink-0 overflow-hidden rounded-full"
style={{ height: size, width: size }}
>
- {/* The drawing carries its own role; hidden so the coworker is announced once, by name. */}
-
-
-
+
);
}
diff --git a/app/src/components/agents/orb/agent-orb.tsx b/app/src/components/agents/orb/agent-orb.tsx
index 9fc2c7f393..4f953f922b 100644
--- a/app/src/components/agents/orb/agent-orb.tsx
+++ b/app/src/components/agents/orb/agent-orb.tsx
@@ -1,437 +1,52 @@
-import {
- type MotionStyle,
- motion,
- type TargetAndTransition,
- type Transition,
- useReducedMotion,
- useTransform,
-} from "motion/react";
import { cn } from "@/lib/utils";
-import {
- type AIAmplitude,
- type AIState,
- getAIStateMotion,
- useAmplitudeValue,
-} from "./ai-core";
-
-const SIZE_THRESHOLD_SMALL = 50;
-const SIZE_THRESHOLD_TINY = 30;
-const SIZE_THRESHOLD_MEDIUM = 100;
-const BLUR_MULTIPLIER_SMALL = 0.008;
-const BLUR_MIN_SMALL = 1;
-const BLUR_MULTIPLIER_LARGE = 0.015;
-const BLUR_MIN_LARGE = 4;
-const CONTRAST_MULTIPLIER_SMALL = 0.004;
-const CONTRAST_MIN_SMALL = 1.2;
-const CONTRAST_MULTIPLIER_LARGE = 0.008;
-const CONTRAST_MIN_LARGE = 1.5;
-const DOT_SIZE_MULTIPLIER_SMALL = 0.004;
-const DOT_SIZE_MIN_SMALL = 0.05;
-const DOT_SIZE_MULTIPLIER_LARGE = 0.008;
-const DOT_SIZE_MIN_LARGE = 0.1;
-const SHADOW_MULTIPLIER_SMALL = 0.004;
-const SHADOW_MIN_SMALL = 0.5;
-const SHADOW_MULTIPLIER_LARGE = 0.008;
-const SHADOW_MIN_LARGE = 2;
-const MASK_RADIUS_TINY = "0%";
-const MASK_RADIUS_SMALL = "5%";
-const MASK_RADIUS_MEDIUM = "15%";
-const MASK_RADIUS_LARGE = "25%";
-const CONTRAST_TINY = 1.1;
-const CONTRAST_MULTIPLIER_FINAL = 1.2;
-const CONTRAST_MIN_FINAL = 1.3;
-
-/** Loud audio tightens the gradient, which reads as the orb "focusing". */
-const AMPLITUDE_BLUR_FALLOFF = 0.45;
-/** Amplitude adds at most 12% of extra size on top of the state scale. */
-const AMPLITUDE_SCALE_GAIN = 0.12;
-/** Single lateral nudge used to signal `error`, well under 200ms. */
-const ERROR_SHAKE_KEYFRAMES = [0, -3, 3, 0];
-const ERROR_SHAKE_DURATION = 0.18;
-const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;
-const GLOW_BLUR_RATIO = 0.28;
-const GLOW_MAX_OPACITY = 0.7;
-/** Depth rim, as a fraction of the orb size. */
-const RIM_RATIO = 0.06;
-const RIM_MIN = 1.5;
-/** Sheen drift period at rest, before the state speed divides it. */
-const DRIFT_BASE_SECONDS = 12;
-/** `idle` breathes: a slow, shallow scale cycle that never draws attention. */
-const BREATHE_SCALE = [1, 1.035, 1];
-const BREATHE_SECONDS = 5.5;
-const SPRING_DEFAULT: Transition = {
- bounce: 0.1,
- duration: 0.25,
- type: "spring",
+import { BotOrb, type BotOrbState } from "./bot-orb";
+
+/** What OpenBot is doing, wherever its own mark stands for it. */
+export type AIState =
+ | "idle"
+ | "listening"
+ | "thinking"
+ | "streaming"
+ | "done"
+ | "error";
+
+/**
+ * Each state as the orb shows it. Streaming is still working. An error rests, and the words beside
+ * the mark say what went wrong.
+ */
+const ORB_STATE: Record = {
+ idle: "idle",
+ listening: "listening",
+ thinking: "thinking",
+ streaming: "thinking",
+ done: "done",
+ error: "idle",
};
export interface AgentOrbProps {
+ className?: string;
/**
- * Live audio level, 0–1. Pass the `MotionValue` from `useAudioAmplitude` so
- * the signal never re-renders React.
+ * Its footprint, in px, as "64px". Only px: the orb is drawn on a canvas at that size, so a share
+ * of its container, as "100%", has nothing to be a share of.
*/
- amplitude?: AIAmplitude;
- /** Ambient rotation period in seconds, before the state speed multiplier. */
- animationDuration?: number;
- className?: string;
- colors?: {
- bg?: string;
- c1?: string;
- c2?: string;
- c3?: string;
- /** Fourth mesh stop. More stops means fewer visible repeats per rotation. */
- c4?: string;
- };
- size?: string;
- /** Shared AI state driving speed, scale, saturation and reactivity. */
+ size?: `${number}px`;
state?: AIState;
}
-const AgentOrb: React.FC = ({
- size = "192px",
+/** OpenBot's own mark: the orb, in OpenBot's violet. */
+export default function AgentOrb({
className,
- colors,
- animationDuration = 20,
- amplitude,
+ size = "192px",
state = "idle",
-}) => {
- const shouldReduceMotion = useReducedMotion();
- const amplitudeValue = useAmplitudeValue(amplitude);
- const stateMotion = getAIStateMotion(state);
-
- /**
- * Higher chroma than the original pastels.
- *
- * The first palette sat at chroma 0.12–0.15, which washes out once the mesh is
- * blurred and the dot pattern is overlaid on top. Pushing to ~0.2 and adding a
- * saturate() pass to the mesh is what makes the colour survive all that.
- */
- const defaultColors = {
- bg: "oklch(92% 0.03 300)",
- c1: "oklch(68% 0.21 350)", // Pink
- c2: "oklch(70% 0.18 210)", // Blue
- c3: "oklch(66% 0.2 285)", // Violet
- c4: "oklch(72% 0.19 325)", // Magenta, the fourth stop for variety
- };
-
- const finalColors = { ...defaultColors, ...colors };
- // The bloom stays in the orb's own palette — see the note in ai-orb-aura.
- const glowColor = finalColors.c2;
-
- // Extract numeric value from size for calculations
- const sizeValue = Number.parseInt(size.replace("px", ""), 10);
-
- // Responsive calculations based on size
- const blurAmount =
- sizeValue < SIZE_THRESHOLD_SMALL
- ? Math.max(sizeValue * BLUR_MULTIPLIER_SMALL, BLUR_MIN_SMALL) // Reduced blur for small sizes
- : Math.max(sizeValue * BLUR_MULTIPLIER_LARGE, BLUR_MIN_LARGE);
-
- const contrastAmount =
- sizeValue < SIZE_THRESHOLD_SMALL
- ? Math.max(sizeValue * CONTRAST_MULTIPLIER_SMALL, CONTRAST_MIN_SMALL) // Reduced contrast for small sizes
- : Math.max(sizeValue * CONTRAST_MULTIPLIER_LARGE, CONTRAST_MIN_LARGE);
-
- const dotSize =
- sizeValue < SIZE_THRESHOLD_SMALL
- ? Math.max(sizeValue * DOT_SIZE_MULTIPLIER_SMALL, DOT_SIZE_MIN_SMALL) // Smaller dots for small sizes
- : Math.max(sizeValue * DOT_SIZE_MULTIPLIER_LARGE, DOT_SIZE_MIN_LARGE);
-
- const shadowSpread =
- sizeValue < SIZE_THRESHOLD_SMALL
- ? Math.max(sizeValue * SHADOW_MULTIPLIER_SMALL, SHADOW_MIN_SMALL) // Reduced shadow for small sizes
- : Math.max(sizeValue * SHADOW_MULTIPLIER_LARGE, SHADOW_MIN_LARGE);
-
- // Adjust mask radius based on size to reduce black center in small sizes
- const getMaskRadius = (value: number) => {
- if (value < SIZE_THRESHOLD_TINY) {
- return MASK_RADIUS_TINY;
- }
- if (value < SIZE_THRESHOLD_SMALL) {
- return MASK_RADIUS_SMALL;
- }
- if (value < SIZE_THRESHOLD_MEDIUM) {
- return MASK_RADIUS_MEDIUM;
- }
- return MASK_RADIUS_LARGE;
- };
-
- const maskRadius = getMaskRadius(sizeValue);
-
- // Use more subtle contrast for very small sizes
- const getFinalContrast = (value: number) => {
- if (value < SIZE_THRESHOLD_TINY) {
- return CONTRAST_TINY; // Very subtle contrast for tiny sizes
- }
- if (value < SIZE_THRESHOLD_SMALL) {
- return Math.max(
- contrastAmount * CONTRAST_MULTIPLIER_FINAL,
- CONTRAST_MIN_FINAL,
- ); // Reduced contrast for small sizes
- }
- return contrastAmount;
- };
-
- const finalContrast = getFinalContrast(sizeValue);
-
- // Reactivity is gated by the state preset: `thinking` barely listens so the
- // orb keeps churning internally instead of throbbing with room noise.
- const reactivity = shouldReduceMotion ? 0 : stateMotion.reactivity;
-
- const reactiveBlur = useTransform(amplitudeValue, (level) => {
- const focus = 1 - level * reactivity * AMPLITUDE_BLUR_FALLOFF;
- return `${blurAmount * focus}px`;
- });
-
- const reactiveScale = useTransform(
- amplitudeValue,
- (level) => stateMotion.scale + level * reactivity * AMPLITUDE_SCALE_GAIN,
- );
-
- const loopDuration = shouldReduceMotion
- ? animationDuration
- : animationDuration / stateMotion.speed;
-
- // Rim thickness scales with the orb, so the lit edge reads the same at 24px
- // and at 240px instead of swallowing the small one.
- const rim = Math.max(sizeValue * RIM_RATIO, RIM_MIN);
- // The sheen drifts slower than the mesh rotates; a busier state speeds it up
- // a touch without matching the rotation, which would look mechanical.
- const driftDuration = (DRIFT_BASE_SECONDS / (1 + stateMotion.speed)) * 2;
-
- const getRootAnimate = (): TargetAndTransition => {
- if (shouldReduceMotion) {
- return { scale: 1, x: 0 };
- }
- if (state === "error") {
- return { scale: 1, x: ERROR_SHAKE_KEYFRAMES };
- }
- if (stateMotion.motif === "breathe") {
- return { scale: BREATHE_SCALE, x: 0 };
- }
- return { scale: 1, x: 0 };
- };
-
- const getRootTransition = (): Transition => {
- if (shouldReduceMotion) {
- return { duration: 0 };
- }
- if (state === "error") {
- return { duration: ERROR_SHAKE_DURATION, ease: EASE_IN_OUT };
- }
- if (stateMotion.motif === "breathe") {
- return {
- duration: BREATHE_SECONDS,
- ease: EASE_IN_OUT,
- repeat: Number.POSITIVE_INFINITY,
- };
- }
- return SPRING_DEFAULT;
- };
-
+}: AgentOrbProps) {
+ if (!/^\d+(\.\d+)?px$/.test(size)) {
+ throw new Error(`AgentOrb's size is in px, as "64px", not "${size}".`);
+ }
return (
- // The gradient disc clips its own overflow, so the bloom and the status
- // motif have to live in a wrapper rather than inside it.
-
- {/* Bloom tinted by the state's semantic accent. */}
-
-
-
- {/* Specular sheen and depth rim. The mesh alone reads as a flat blurred
- disc; a drifting highlight plus a lit top edge and shaded bottom are
- what make it read as a sphere. */}
-
-
-
-
-
+
);
-};
-
-export default AgentOrb;
+}
diff --git a/app/src/components/agents/orb/ai-core.tsx b/app/src/components/agents/orb/ai-core.tsx
deleted file mode 100644
index a1f13a6361..0000000000
--- a/app/src/components/agents/orb/ai-core.tsx
+++ /dev/null
@@ -1,395 +0,0 @@
-"use client";
-
-import {
- type MotionValue,
- useAnimationFrame,
- useMotionValue,
-} from "motion/react";
-import { useCallback, useEffect, useRef, useState } from "react";
-
-/**
- * The single state contract shared by every SmoothUI AI component.
- *
- * Passing the same value to an orb, a prompt input and a tool card makes the
- * whole surface move as one organism instead of a set of independent widgets.
- */
-export type AIState =
- | "idle"
- | "listening"
- | "thinking"
- | "streaming"
- | "done"
- | "error";
-
-/**
- * A behavioural hint each component expresses **in its own material**.
- *
- * Deliberately not an overlay. Bolting one shared graphic onto every orb makes
- * four different materials look like the same widget wearing a costume, and it
- * pushes literal iconography (a checkmark, a warning ring) onto pieces that are
- * decorative by nature. Instead: a shader warps, a canvas ring loses harmonics,
- * a character changes expression. Same vocabulary, different flesh.
- */
-export type AIStateMotif =
- | "breathe"
- | "receive"
- | "scan"
- | "pulse"
- | "ping"
- | "fault";
-
-/** Semantic accent applied on top of the component's own palette. */
-export type AIStateAccent = "success" | "danger" | null;
-
-/** Motion parameters a component reads to render a given {@link AIState}. */
-export type AIStateMotion = {
- /** Semantic colour override, so status is carried by hue and not only motion. */
- accent: AIStateAccent;
- /** Outer bloom strength, 0–1. */
- glow: number;
- /** Palette hue rotation in degrees. Small shifts read as a mood change. */
- hueRotate: number;
- /** Overall motion energy, 0–1. Scales ambient loops and displacement. */
- intensity: number;
- /** Behavioural hint; each component expresses it in its own material. */
- motif: AIStateMotif;
- /** Seconds between discrete pulses, for rhythmic behaviour. */
- pulseSeconds: number;
- /**
- * How far a shader's domain warp pushes the field, 0–1. Low values read as a
- * calm surface; high values churn without the silhouette growing.
- */
- turbulence: number;
- /** Revolutions per second of the noise field — the slow tumble. */
- tumble: number;
- /** How much external amplitude reaches the surface, 0 = ignore it. */
- reactivity: number;
- /** Chroma multiplier. Below 1 desaturates. */
- saturation: number;
- /** Resting scale of the surface, 1 = no change. */
- scale: number;
- /** Ambient loop speed multiplier, 1 = the component's base tempo. */
- speed: number;
-};
-
-/**
- * Per-state presets.
- *
- * Deliberate choices worth keeping:
- * - `thinking` has `scale: 1` — internal churn only, so layout stays calm while
- * the model works. Growing the surface here makes pages feel unstable.
- * - `error` desaturates instead of growing, so it reads as a state change
- * rather than an attention grab.
- * - `done` overshoots once; components are expected to settle back to `idle`.
- * - Each state owns a distinct `motif`, so the six states are told apart by
- * what moves, not by how fast it moves.
- */
-export const AI_STATE_MOTION: Record = {
- done: {
- accent: "success",
- glow: 0.7,
- hueRotate: 0,
- intensity: 0.4,
- motif: "ping",
- pulseSeconds: 0.65,
- reactivity: 0,
- saturation: 1,
- scale: 1.1,
- speed: 0.8,
- // The field settles almost flat — stillness is what reads as "finished".
- tumble: 0.02,
- turbulence: 0.08,
- },
- error: {
- accent: "danger",
- glow: 0.25,
- hueRotate: 0,
- intensity: 0.5,
- motif: "fault",
- pulseSeconds: 0.9,
- reactivity: 0,
- saturation: 0.3,
- scale: 0.96,
- speed: 1,
- // Tumble stalls while turbulence stays mid: the surface twitches in place
- // instead of flowing, which is what a fault feels like.
- tumble: 0,
- turbulence: 0.55,
- },
- idle: {
- accent: null,
- glow: 0.15,
- hueRotate: 0,
- intensity: 0.3,
- motif: "breathe",
- pulseSeconds: 4.5,
- reactivity: 0,
- saturation: 0.75,
- scale: 0.94,
- speed: 0.6,
- tumble: 0.012,
- turbulence: 0.14,
- },
- listening: {
- accent: null,
- glow: 0.6,
- hueRotate: 0,
- intensity: 0.75,
- motif: "receive",
- pulseSeconds: 1.6,
- reactivity: 1,
- saturation: 1.05,
- scale: 1.06,
- speed: 1,
- tumble: 0.03,
- turbulence: 0.42,
- },
- streaming: {
- accent: null,
- glow: 0.45,
- hueRotate: -10,
- intensity: 0.6,
- motif: "pulse",
- pulseSeconds: 1.25,
- reactivity: 0.6,
- saturation: 1,
- scale: 1.02,
- speed: 1.4,
- tumble: 0.06,
- turbulence: 0.5,
- },
- thinking: {
- accent: null,
- glow: 0.35,
- hueRotate: 18,
- intensity: 1,
- motif: "scan",
- pulseSeconds: 1.1,
- reactivity: 0.15,
- saturation: 1,
- scale: 1,
- speed: 2.4,
- // High churn, unchanged silhouette: the work is visible without the orb
- // growing and unsettling the layout around it.
- tumble: 0.14,
- turbulence: 0.95,
- },
-};
-
-/** Semantic accents. Deliberately not tokens — orbs render outside a theme. */
-export const AI_ACCENT_COLORS: Record<"success" | "danger", string> = {
- danger: "oklch(63% 0.21 25)",
- success: "oklch(72% 0.17 150)",
-};
-
-/**
- * Colour used by a state's overlay motif: the semantic accent when the state
- * has one, otherwise the component's own secondary colour.
- */
-export const getAIStateAccentColor = (
- state: AIState | undefined,
- fallback: string,
-): string => {
- const accent = AI_STATE_MOTION[state ?? "idle"]?.accent;
- return accent ? AI_ACCENT_COLORS[accent] : fallback;
-};
-
-/** Motion preset for a state, falling back to `idle` for unknown values. */
-export const getAIStateMotion = (state: AIState | undefined): AIStateMotion =>
- AI_STATE_MOTION[state ?? "idle"] ?? AI_STATE_MOTION.idle;
-
-/**
- * Amplitude accepted by every reactive AI component.
- *
- * A `MotionValue` is the preferred form: it updates outside React, so a 60fps
- * audio signal never triggers a re-render.
- */
-export type AIAmplitude = number | MotionValue | undefined;
-
-const isMotionValue = (value: AIAmplitude): value is MotionValue =>
- typeof value === "object" && value !== null && "get" in value;
-
-/**
- * Normalises the `amplitude` prop into a stable `MotionValue` so
- * component internals only deal with one shape.
- */
-export const useAmplitudeValue = (
- amplitude: AIAmplitude,
-): MotionValue => {
- const fallback = useMotionValue(0);
- const numeric = typeof amplitude === "number" ? amplitude : null;
-
- useEffect(() => {
- if (numeric !== null) {
- fallback.set(numeric);
- }
- }, [numeric, fallback]);
-
- return isMotionValue(amplitude) ? amplitude : fallback;
-};
-
-export type AudioAmplitudeStatus =
- | "idle"
- | "requesting"
- | "active"
- | "denied"
- | "unsupported";
-
-export type UseAudioAmplitudeOptions = {
- /** Request microphone access as soon as the hook mounts. */
- autoStart?: boolean;
- /**
- * Envelope smoothing, 0–1. Higher is smoother and lazier; the default keeps
- * attack snappy so an orb reacts on the first syllable.
- */
- smoothing?: number;
- /** FFT size handed to the analyser node. Must be a power of two. */
- fftSize?: number;
-};
-
-export type UseAudioAmplitudeResult = {
- /** Smoothed RMS level, 0–1, as a `MotionValue`. */
- amplitude: MotionValue;
- status: AudioAmplitudeStatus;
- start: () => Promise;
- stop: () => void;
-};
-
-const DEFAULT_SMOOTHING = 0.55;
-const DEFAULT_FFT_SIZE = 512;
-/** Raw RMS rarely exceeds ~0.3 for speech, so normalise into a usable 0–1. */
-const RMS_TO_UNIT = 3.2;
-/** Attack is faster than release so peaks land immediately and decay gently. */
-const ATTACK_FACTOR = 0.35;
-
-/**
- * Reads microphone loudness as a 0–1 `MotionValue`.
- *
- * SSR-safe, permission-aware, and silent on failure — a denied prompt leaves
- * the amplitude at 0 so the consuming component simply falls back to its
- * ambient animation.
- */
-export const useAudioAmplitude = (
- options: UseAudioAmplitudeOptions = {},
-): UseAudioAmplitudeResult => {
- const {
- autoStart = false,
- smoothing = DEFAULT_SMOOTHING,
- fftSize = DEFAULT_FFT_SIZE,
- } = options;
-
- const amplitude = useMotionValue(0);
- const [status, setStatus] = useState("idle");
-
- const contextRef = useRef(null);
- const streamRef = useRef(null);
- const analyserRef = useRef(null);
- const bufferRef = useRef | null>(null);
-
- const stop = useCallback(() => {
- for (const track of streamRef.current?.getTracks() ?? []) {
- track.stop();
- }
- streamRef.current = null;
- analyserRef.current = null;
- bufferRef.current = null;
- contextRef.current?.close();
- contextRef.current = null;
- amplitude.set(0);
- setStatus("idle");
- }, [amplitude]);
-
- const start = useCallback(async () => {
- if (analyserRef.current) {
- return;
- }
-
- const AudioContextCtor =
- typeof window === "undefined"
- ? undefined
- : (window.AudioContext ??
- (window as unknown as { webkitAudioContext?: typeof AudioContext })
- .webkitAudioContext);
-
- if (!(AudioContextCtor && navigator.mediaDevices?.getUserMedia)) {
- setStatus("unsupported");
- return;
- }
-
- setStatus("requesting");
-
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- const context = new AudioContextCtor();
- const analyser = context.createAnalyser();
- analyser.fftSize = fftSize;
- context.createMediaStreamSource(stream).connect(analyser);
-
- streamRef.current = stream;
- contextRef.current = context;
- analyserRef.current = analyser;
- bufferRef.current = new Float32Array(analyser.fftSize);
- setStatus("active");
- } catch {
- setStatus("denied");
- }
- }, [fftSize]);
-
- useEffect(() => {
- if (autoStart) {
- start();
- }
- return stop;
- }, [autoStart, start, stop]);
-
- useAnimationFrame(() => {
- const analyser = analyserRef.current;
- const buffer = bufferRef.current;
- if (!(analyser && buffer)) {
- return;
- }
-
- analyser.getFloatTimeDomainData(buffer);
-
- let sumOfSquares = 0;
- for (const sample of buffer) {
- sumOfSquares += sample * sample;
- }
- const rms = Math.sqrt(sumOfSquares / buffer.length);
- const target = Math.min(1, rms * RMS_TO_UNIT);
-
- const previous = amplitude.get();
- const factor = target > previous ? smoothing * ATTACK_FACTOR : smoothing;
- amplitude.set(previous + (target - previous) * (1 - factor));
- });
-
- return { amplitude, start, status, stop };
-};
-
-/**
- * Amplitude generator for demos, docs and previews — no microphone involved.
- *
- * Produces a plausible speech-like envelope whose energy follows the current
- * {@link AIState}, so every example can show the reactive behaviour without
- * asking the visitor for permissions.
- */
-export const useSimulatedAmplitude = (
- state: AIState = "idle",
-): MotionValue => {
- const amplitude = useMotionValue(0);
- const motion = getAIStateMotion(state);
-
- useAnimationFrame((time) => {
- const t = time / 1000;
- // Three detuned sines read as organic; a single sine reads as a metronome.
- const envelope =
- 0.5 +
- 0.3 * Math.sin(t * 2.1 * motion.speed) +
- 0.14 * Math.sin(t * 5.3 * motion.speed + 1.7) +
- 0.06 * Math.sin(t * 11.7 * motion.speed + 0.4);
-
- amplitude.set(Math.min(1, Math.max(0, envelope * motion.intensity)));
- });
-
- return amplitude;
-};
diff --git a/app/src/components/agents/orb/bot-orb-draw.ts b/app/src/components/agents/orb/bot-orb-draw.ts
new file mode 100644
index 0000000000..f3e3f2b370
--- /dev/null
+++ b/app/src/components/agents/orb/bot-orb-draw.ts
@@ -0,0 +1,782 @@
+/**
+ * The orb, every Bot's face and OpenBot's own mark, drawn on a 2D canvas: a glass sphere of
+ * drifting colour with two eye-lights that look, blink and smile. It comes in nine colours. A Bot's
+ * look, its colour and the few small ways two Bots in one colour still differ, is picked from its
+ * seed; OpenBot's own is violet, which no Bot is given. It also plays a finish: the check and the
+ * burst of rays the orb shows when something the person allowed is done.
+ *
+ * Every frame is a pure function of an `OrbPose`, so the component that animates it owns time and
+ * this file owns only the picture.
+ */
+
+const TAU = Math.PI * 2;
+const PI = Math.PI;
+
+export const clamp = (x: number, a = 0, b = 1) => (x < a ? a : x > b ? b : x);
+const lerp = (a: number, b: number, u: number) => a + (b - a) * u;
+const eio = (u: number) => {
+ const v = clamp(u);
+ return v < 0.5 ? 4 * v * v * v : 1 - (-2 * v + 2) ** 3 / 2;
+};
+const eout = (u: number) => 1 - (1 - clamp(u)) ** 3;
+const smooth = (u: number) => {
+ const v = clamp(u);
+ return v * v * (3 - 2 * v);
+};
+const bump = (u: number) => (u <= 0 || u >= 1 ? 0 : Math.sin(PI * u) ** 2);
+
+// ---------- motion: keyed tracks with eases and closed-form springs ----------
+
+/** One move of a track: to `to` from time `at`, over an ease of `ease` seconds or on a spring of [stiffness, damping]. */
+export type TrackKey =
+ | { at: number; to: number; ease: number }
+ | { at: number; to: number; spring: readonly [number, number] };
+
+/** A value that starts at `from` and moves through its keys in order. */
+export type Track = { from: number; keys: TrackKey[] };
+
+/** Step response of a damped spring: closed form, so it is a pure function of elapsed time. */
+function springStep(tau: number, w: number, z: number) {
+ if (tau <= 0) return 0;
+ if (z >= 1) return 1 - Math.exp(-w * tau) * (1 + w * tau);
+ const wd = w * Math.sqrt(1 - z * z);
+ return (
+ 1 -
+ Math.exp(-z * w * tau) *
+ (Math.cos(wd * tau) + ((z * w) / wd) * Math.sin(wd * tau))
+ );
+}
+
+/**
+ * The track's value at time t. Each key moves the value from the previous key's value to its own,
+ * and the responses are summed, which for springs is exactly a spring chasing a stepped target:
+ * anticipation, overshoot and settle all fall out of the same maths.
+ */
+export function track(tr: Track, t: number) {
+ let v = tr.from;
+ let prev = tr.from;
+ for (const k of tr.keys) {
+ if (t < k.at) break;
+ const tau = t - k.at;
+ const r =
+ "ease" in k
+ ? eio(tau / k.ease)
+ : springStep(tau, k.spring[0], k.spring[1]);
+ v += (k.to - prev) * r;
+ prev = k.to;
+ }
+ return v;
+}
+
+/** A blink at `tau` seconds after it starts: shut fast, open slower, 0.24 s in all. */
+export function blinkCurve(tau: number) {
+ if (tau < 0 || tau > 0.24) return 0;
+ return tau < 0.07 ? eout(tau / 0.07) : 1 - eio((tau - 0.07) / 0.17);
+}
+
+/** A nod that rings down, `tau` seconds after it starts. */
+export function nodCurve(tau: number) {
+ return tau >= 0 && tau < 2.2 ? Math.exp(-3 * tau) * Math.sin(8 * tau) : 0;
+}
+
+export function mulberry32(seed: number) {
+ let a = seed;
+ return () => {
+ a |= 0;
+ a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * Where it looks while nothing needs it: mostly at you, now and then a glance aside and back.
+ * Each look snaps over 0.14 s and then holds, so the pauses are part of the motion.
+ */
+export function restLook(ta: number, period: number, seed: number) {
+ const k = Math.floor(ta / period);
+ const f = ta - k * period;
+ const at = (n: number): [number, number, number] => {
+ const r = mulberry32((seed * 7919 + n * 104729) | 0);
+ return r() < 0.55
+ ? [0, 0, 0]
+ : [(r() * 2 - 1) * 0.7, (r() * 2 - 1) * 0.4, (r() * 2 - 1) * 0.24];
+ };
+ const a = at(k - 1);
+ const b = at(k);
+ const u = eout(f / 0.14);
+ return [lerp(a[0], b[0], u), lerp(a[1], b[1], u), lerp(a[2], b[2], u)];
+}
+
+// ---------- colour ----------
+
+export type Rgb = [number, number, number];
+
+function ok2rgb(L: number, C: number, h: number): Rgb | null {
+ const hr = (h * PI) / 180;
+ const a = C * Math.cos(hr);
+ const b = C * Math.sin(hr);
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
+ const linear = [
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
+ ];
+ const out: number[] = [];
+ for (const x of linear) {
+ if (x < -0.002 || x > 1.002) return null;
+ const c = clamp(x);
+ out.push(
+ Math.round(
+ 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055),
+ ),
+ );
+ }
+ return out as Rgb;
+}
+
+/** An oklch colour as sRGB, losing chroma until it fits rather than clipping its hue. */
+export function oklch(L: number, C: number, h: number): Rgb {
+ let chroma = C;
+ for (let i = 0; i < 40; i++) {
+ const c = ok2rgb(L, chroma, h);
+ if (c) return c;
+ chroma *= 0.95;
+ }
+ return ok2rgb(L, 0, h) ?? [128, 128, 128];
+}
+
+const rgba = (c: Rgb, a: number) =>
+ `rgba(${c[0]},${c[1]},${c[2]},${a <= 0 ? 0 : a >= 1 ? 1 : a.toFixed(3)})`;
+const mix = (a: Rgb, b: Rgb, u: number): Rgb => [
+ Math.round(lerp(a[0], b[0], u)),
+ Math.round(lerp(a[1], b[1], u)),
+ Math.round(lerp(a[2], b[2], u)),
+];
+const WHITE: Rgb = [255, 255, 255];
+
+type Oklch = readonly [L: number, C: number, h: number];
+
+/**
+ * One of the orb's colours, as oklch [L, C, h]: four tones for the sphere's drifting colour and its
+ * pale base. `main` also tints its glow and, in the dark, the landing's ping and a finish's rays;
+ * `deep` draws those in the light. `shade` is the tint of the sphere's shaded rim, as sRGB.
+ */
+type PaletteSpec = {
+ main: Oklch;
+ side: Oklch;
+ deep: Oklch;
+ pale: Oklch;
+ base: Oklch;
+ shade: readonly [r: number, g: number, b: number];
+};
+
+/**
+ * The nine colours: violet, OpenBot's own, and the eight a Bot's look is picked from by position.
+ * Adding, removing or reordering one changes the colour of most Bots.
+ */
+const PALETTES = {
+ violet: {
+ main: [0.7, 0.14, 288],
+ side: [0.72, 0.15, 318],
+ deep: [0.47, 0.2, 296],
+ pale: [0.86, 0.08, 308],
+ base: [0.93, 0.03, 300],
+ shade: [40, 16, 78],
+ },
+ ember: {
+ main: [0.79, 0.13, 62],
+ side: [0.75, 0.15, 35],
+ deep: [0.53, 0.17, 30],
+ pale: [0.89, 0.08, 52],
+ base: [0.94, 0.03, 50],
+ shade: [84, 30, 14],
+ },
+ moss: {
+ main: [0.77, 0.11, 182],
+ side: [0.78, 0.13, 152],
+ deep: [0.5, 0.12, 165],
+ pale: [0.89, 0.07, 160],
+ base: [0.94, 0.03, 160],
+ shade: [14, 60, 40],
+ },
+ sun: {
+ main: [0.81, 0.15, 72],
+ side: [0.85, 0.14, 92],
+ deep: [0.57, 0.14, 58],
+ pale: [0.93, 0.08, 92],
+ base: [0.95, 0.03, 90],
+ shade: [92, 58, 10],
+ },
+ berry: {
+ main: [0.7, 0.15, 328],
+ side: [0.72, 0.16, 352],
+ deep: [0.48, 0.18, 345],
+ pale: [0.86, 0.08, 350],
+ base: [0.93, 0.03, 350],
+ shade: [80, 14, 48],
+ },
+ ocean: {
+ main: [0.74, 0.13, 205],
+ side: [0.75, 0.13, 232],
+ deep: [0.53, 0.2, 262],
+ pale: [0.86, 0.08, 220],
+ base: [0.93, 0.025, 235],
+ shade: [14, 30, 80],
+ },
+ // Beside ocean on the wheel, so lighter and softer: a clear sky against ocean's deep water.
+ sky: {
+ main: [0.79, 0.11, 220],
+ side: [0.8, 0.1, 242],
+ deep: [0.55, 0.15, 245],
+ pale: [0.9, 0.06, 228],
+ base: [0.95, 0.02, 230],
+ shade: [10, 40, 76],
+ },
+ coral: {
+ main: [0.78, 0.13, 32],
+ side: [0.74, 0.15, 8],
+ deep: [0.52, 0.17, 18],
+ pale: [0.88, 0.08, 22],
+ base: [0.94, 0.03, 20],
+ shade: [84, 20, 24],
+ },
+ // Nearly grey: for a team big enough to want a quiet one.
+ slate: {
+ main: [0.72, 0.045, 232],
+ side: [0.76, 0.04, 250],
+ deep: [0.48, 0.05, 265],
+ pale: [0.88, 0.02, 255],
+ base: [0.93, 0.012, 258],
+ shade: [30, 38, 54],
+ },
+} as const satisfies Record;
+
+export type OrbPaletteName = keyof typeof PALETTES;
+
+/** Every colour, OpenBot's violet first. */
+export const ORB_PALETTES = Object.keys(PALETTES) as readonly OrbPaletteName[];
+
+/** The eight colours a Bot can be: all but violet, so a Bot is never mistaken for OpenBot. */
+const BOT_PALETTES = ORB_PALETTES.filter((name) => name !== "violet");
+
+/**
+ * How one orb is drawn apart from its motion: its colour, how far round its colour masses are
+ * turned, in radians, a nudge to its hue in degrees, and how far apart its eyes are, as a share of
+ * the usual spacing.
+ */
+export type OrbLook = {
+ palette: OrbPaletteName;
+ turn: number;
+ hue: number;
+ eyes: number;
+};
+
+/** OpenBot's own look: violet, untouched. */
+export const OPENBOT_LOOK: OrbLook = {
+ palette: "violet",
+ turn: 0,
+ hue: 0,
+ eyes: 1,
+};
+
+/** The most a Bot's hue is nudged either way, in degrees: a touch, never enough to pass for another colour. */
+const HUE_NUDGE = 4;
+/** The most a Bot's eyes are moved apart or together, as a share of the usual spacing. */
+const EYE_NUDGE = 0.08;
+
+/** A 32-bit FNV-1a hash of the seed, mixed with murmur3's finaliser so every character moves every bit. */
+function hashSeed(seed: string): number {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < seed.length; i++) {
+ h ^= seed.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ h ^= h >>> 16;
+ h = Math.imul(h, 0x85ebca6b);
+ h ^= h >>> 13;
+ h = Math.imul(h, 0xc2b2ae35);
+ h ^= h >>> 16;
+ return h >>> 0;
+}
+
+/**
+ * The look for a Bot's seed. Eight colours repeat soon in a team, so the same hash also turns the
+ * colour masses, nudges the hue and spaces the eyes, and two Bots in one colour still look apart.
+ * Stable across reloads and releases, so a Bot keeps its face.
+ */
+export function orbLookFor(seed: string): OrbLook {
+ const h = hashSeed(seed);
+ // The eight colours take the low three bits, and each of the rest the next eight.
+ const share = (from: number) => ((h >>> from) & 0xff) / 256;
+ return {
+ palette: BOT_PALETTES[h % BOT_PALETTES.length] as OrbPaletteName,
+ turn: share(3) * TAU,
+ hue: Math.round((share(11) * 2 - 1) * HUE_NUDGE),
+ eyes: 1 + (share(19) * 2 - 1) * EYE_NUDGE,
+ };
+}
+
+/** A colour's tones as sRGB, ready to draw. */
+export type Palette = Record;
+const paletteMemo = new Map();
+
+/** A colour with its saturation and hue nudged, as the orb livens when it listens and cools when it works. */
+export function palette(
+ name: OrbPaletteName,
+ sat: number,
+ hue: number,
+): Palette {
+ const ks = Math.round(clamp(sat, 0.5, 1.3) * 50) / 50;
+ const kh = Math.round(hue * 2) / 2;
+ const key = `${name}|${ks}|${kh}`;
+ let p = paletteMemo.get(key);
+ if (!p) {
+ const spec: PaletteSpec = PALETTES[name];
+ const c = ([L, C, h]: Oklch, sk = ks, hk = kh) => oklch(L, C * sk, h + hk);
+ p = {
+ main: c(spec.main),
+ side: c(spec.side),
+ deep: c(spec.deep),
+ pale: c(spec.pale),
+ base: c(spec.base, 1, kh * 0.5),
+ shade: [...spec.shade],
+ };
+ paletteMemo.set(key, p);
+ }
+ return p;
+}
+
+// ---------- the pose ----------
+
+/** Everything one frame depends on. `ta` is seconds on the orb's own clock; ages are -1 when nothing happened yet. */
+export type OrbPose = {
+ ta: number;
+ /** 0 at rest, 1 listening (can overshoot a little). */
+ listen: number;
+ think: number;
+ /** The landing's glow, 0 to 1. */
+ done: number;
+ smile: number;
+ tilt: number;
+ /** Where it looks: right and down, about -1 to 1. */
+ ax: number;
+ ay: number;
+ blink: number;
+ nod: number;
+ doneAge: number;
+ noticeAge: number;
+ /** Seconds into a finish (a check and a burst of rays, `finishPhase`), or -1 when none is playing. */
+ finishAge: number;
+ /** -1 to 1, a slow breath. */
+ breath: number;
+};
+
+// ---------- the finish ----------
+
+/** How long a finish plays, in seconds, from the eyes giving way to the check until they are back. */
+export const FINISH_S = 1.2;
+/** A moment in a finish when the check is whole and the rays have gone: the finish as a still. */
+export const FINISH_HELD = 0.8;
+/** The short strokes that burst out round the orb when it finishes. */
+export const RAYS = 10;
+
+/** Where a finish is at one moment. Every part of it is a pure function of its age. */
+export type FinishPhase = {
+ /** How much of the check is drawn along its stroke, 0 to 1. */
+ check: number;
+ /** The check's opacity: whole while it holds, gone as the eyes come back. */
+ checkAlpha: number;
+ /** How far the rays have flown, 0 to 1, or -1 before they leave and once they have faded. */
+ rays: number;
+ /** The eye-lights' opacity: they give way to the check and come back after it. */
+ eyes: number;
+ /** The sphere's pop as the check lands, 0 to 1. */
+ pop: number;
+};
+
+const NO_FINISH: FinishPhase = {
+ check: 0,
+ checkAlpha: 0,
+ rays: -1,
+ eyes: 1,
+ pop: 0,
+};
+
+/**
+ * A finish `age` seconds in: the eyes fade as the sphere pops and the check strokes on, the rays
+ * burst outward and fade, the check holds, then fades as the eyes come back. -1 is no finish.
+ */
+export function finishPhase(age: number): FinishPhase {
+ if (!(age >= 0 && age < FINISH_S)) return NO_FINISH;
+ const back = smooth((age - 0.85) / 0.3);
+ return {
+ check: eout((age - 0.06) / 0.3),
+ checkAlpha: 1 - back,
+ rays: age >= 0.1 && age < 0.75 ? (age - 0.1) / 0.65 : -1,
+ eyes: age < 0.85 ? 1 - smooth(age / 0.12) : back,
+ pop: bump(age / 0.5),
+ };
+}
+
+// ---------- the orb's pieces ----------
+
+type Ctx = CanvasRenderingContext2D;
+
+function blob(ctx: Ctx, x: number, y: number, r: number, c: Rgb, a: number) {
+ if (a <= 0.003 || r <= 0) return;
+ const g = ctx.createRadialGradient(x, y, 0, x, y, r);
+ g.addColorStop(0, rgba(c, a));
+ g.addColorStop(0.42, rgba(c, a * 0.78));
+ g.addColorStop(0.76, rgba(c, a * 0.24));
+ g.addColorStop(1, rgba(c, 0));
+ ctx.fillStyle = g;
+ ctx.fillRect(x - r, y - r, 2 * r, 2 * r);
+}
+
+function light(ctx: Ctx, x: number, y: number, r: number, a: number) {
+ if (a <= 0.003 || r <= 0) return;
+ const g = ctx.createRadialGradient(x, y, 0, x, y, r);
+ g.addColorStop(0, `rgba(255,255,255,${clamp(a).toFixed(3)})`);
+ g.addColorStop(0.45, `rgba(255,255,255,${clamp(a * 0.42).toFixed(3)})`);
+ g.addColorStop(1, "rgba(255,255,255,0)");
+ ctx.fillStyle = g;
+ ctx.fillRect(x - r, y - r, 2 * r, 2 * r);
+}
+
+function circle(ctx: Ctx, r: number) {
+ ctx.beginPath();
+ ctx.arc(0, 0, r, 0, TAU);
+}
+
+// angle, orbit, radius of the mesh's four colour masses
+const MESH = [
+ [3.95, 0.43, 0.88],
+ [5.5, 0.45, 0.74],
+ [0.78, 0.45, 0.86],
+ [2.36, 0.45, 0.88],
+] as const;
+
+function meshPos(ta: number, fast: boolean, turn: number) {
+ const rot = turn + (fast ? 1.45 * ta + 1.1 : 0.22 * ta);
+ return MESH.map((b, i) => {
+ const a =
+ b[0] +
+ rot +
+ (fast
+ ? 0.5 * Math.sin(1.1 * ta + i * 1.93)
+ : 0.42 * Math.sin(0.29 * ta + i * 1.93));
+ return [Math.cos(a) * b[1], Math.sin(a) * b[1], b[2]] as const;
+ });
+}
+
+/**
+ * Four soft colour masses drifting round a pale base, starting `turn` radians round; a faster swirl
+ * joins them while it works.
+ */
+function mesh(
+ ctx: Ctx,
+ R: number,
+ S: OrbPose,
+ pal: Palette,
+ turn: number,
+ lx: number,
+ ly: number,
+) {
+ ctx.fillStyle = rgba(pal.base, 1);
+ ctx.fillRect(-R * 1.7, -R * 1.7, R * 3.4, R * 3.4);
+ const cols = [pal.main, pal.pale, pal.side, pal.deep];
+ meshPos(S.ta, false, turn).forEach((p, i) => {
+ blob(ctx, p[0] * R + lx * R, p[1] * R + ly * R, p[2] * R, cols[i], 0.94);
+ });
+ const swirl = S.think * 0.55;
+ if (swirl > 0.01) {
+ meshPos(S.ta, true, turn).forEach((p, i) => {
+ blob(
+ ctx,
+ p[0] * R + lx * R * 0.6,
+ p[1] * R + ly * R * 0.6,
+ p[2] * R * 0.92,
+ cols[i],
+ 0.9 * swirl,
+ );
+ });
+ }
+}
+
+/** Lit top edge, shaded bottom: what makes the disc read as a sphere. Call inside the clip. */
+function rim(ctx: Ctx, R: number, shade: Rgb, dark: boolean) {
+ const g = ctx.createRadialGradient(
+ 0,
+ -R * 0.3,
+ R * 0.55,
+ 0,
+ -R * 0.12,
+ R * 1.16,
+ );
+ g.addColorStop(0, rgba(shade, 0));
+ g.addColorStop(0.72, rgba(shade, 0));
+ g.addColorStop(1, rgba(shade, dark ? 0.3 : 0.24));
+ ctx.fillStyle = g;
+ ctx.fillRect(-R * 1.3, -R * 1.3, R * 2.6, R * 2.6);
+ const lw = Math.max(1, R * 0.05);
+ const lg = ctx.createLinearGradient(0, -R, 0, R * 0.25);
+ lg.addColorStop(0, "rgba(255,255,255,.7)");
+ lg.addColorStop(1, "rgba(255,255,255,0)");
+ ctx.strokeStyle = lg;
+ ctx.lineWidth = lw;
+ circle(ctx, R - lw * 0.5);
+ ctx.stroke();
+}
+
+function glow(ctx: Ctx, R: number, amt: number, c: Rgb, dark: boolean) {
+ if (amt < 0.005) return;
+ const r = R * 1.85;
+ const g = ctx.createRadialGradient(0, 0, R * 0.55, 0, 0, r);
+ g.addColorStop(0, rgba(c, amt * (dark ? 0.8 : 0.5)));
+ g.addColorStop(0.38, rgba(c, amt * (dark ? 0.34 : 0.18)));
+ g.addColorStop(1, rgba(c, 0));
+ ctx.fillStyle = g;
+ ctx.fillRect(-r, -r, 2 * r, 2 * r);
+}
+
+/** How long the landing's ping rings, in seconds. */
+const PING_S = 1;
+
+/** One ring that leaves the orb and fades: the landing's ping. */
+function ping(ctx: Ctx, r0: number, age: number, pal: Palette, dark: boolean) {
+ if (!(age >= 0 && age < PING_S)) return;
+ const u = age / PING_S;
+ const r = r0 * (1.02 + (1.5 - 1.02) * eout(u));
+ const a = (1 - u) ** 1.5;
+ ctx.strokeStyle = rgba(
+ dark ? mix(pal.main, WHITE, 0.2) : pal.deep,
+ a * (dark ? 0.75 : 0.62),
+ );
+ ctx.lineWidth = Math.max(1, r0 * 0.07 * (1 - u * 0.6));
+ circle(ctx, r);
+ ctx.stroke();
+}
+
+/**
+ * The finish's burst: a ring of short strokes, one straight up, that fly out round the sphere,
+ * shortening and fading as they go. Every other one is longer, so the ring sparkles rather than ticks.
+ */
+function rays(ctx: Ctx, r0: number, u: number, pal: Palette, dark: boolean) {
+ if (!(u >= 0 && u < 1)) return;
+ const travel = eout(u);
+ const a = (1 - u) ** 1.4;
+ ctx.save();
+ ctx.lineCap = "round";
+ ctx.strokeStyle = rgba(
+ dark ? mix(pal.main, WHITE, 0.25) : pal.deep,
+ a * (dark ? 0.9 : 0.8),
+ );
+ ctx.lineWidth = Math.max(1, r0 * 0.09 * (1 - 0.5 * u));
+ for (let i = 0; i < RAYS; i++) {
+ const angle = -PI / 2 + (TAU * i) / RAYS;
+ const length = r0 * (i % 2 === 0 ? 0.3 : 0.2) * (1 - 0.55 * u);
+ const inner = r0 * (1.3 + 0.32 * travel);
+ const dx = Math.cos(angle);
+ const dy = Math.sin(angle);
+ ctx.beginPath();
+ ctx.moveTo(dx * inner, dy * inner);
+ ctx.lineTo(dx * (inner + length), dy * (inner + length));
+ ctx.stroke();
+ }
+ ctx.restore();
+}
+
+// The check's three points, as shares of the sphere's radius: in at the left, down to the elbow, up
+// to the right. Its two legs are about 0.37 and 0.75 of the radius long.
+const CHECK = [
+ [-0.38, 0.02],
+ [-0.12, 0.28],
+ [0.42, -0.26],
+] as const;
+
+/** The check, stroked on along its length to `drawn` (0 to 1), in the eye-lights' white. */
+function check(
+ ctx: Ctx,
+ R: number,
+ drawn: number,
+ alpha: number,
+ dark: boolean,
+) {
+ if (drawn <= 0 || alpha <= 0.003) return;
+ const [a, b, c] = CHECK.map(([x, y]) => [x * R, y * R] as const);
+ const first = Math.hypot(b[0] - a[0], b[1] - a[1]);
+ const second = Math.hypot(c[0] - b[0], c[1] - b[1]);
+ const along = drawn * (first + second);
+ ctx.save();
+ ctx.globalAlpha = clamp(alpha);
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.lineWidth = Math.max(1.6, R * 0.17);
+ ctx.strokeStyle = "rgba(255,255,255,.97)";
+ ctx.shadowColor = dark ? "rgba(8,6,20,.4)" : "rgba(20,16,33,.28)";
+ ctx.shadowBlur = Math.max(1, R * 0.07);
+ ctx.beginPath();
+ ctx.moveTo(a[0], a[1]);
+ if (along <= first) {
+ const u = along / first;
+ ctx.lineTo(lerp(a[0], b[0], u), lerp(a[1], b[1], u));
+ } else {
+ const u = (along - first) / second;
+ ctx.lineTo(b[0], b[1]);
+ ctx.lineTo(lerp(b[0], c[0], u), lerp(b[1], c[1], u));
+ }
+ ctx.stroke();
+ ctx.restore();
+}
+
+/** The sphere's scale: it breathes, swells a little to listen and to land, and pops as a check lands. */
+function scaleOf(S: OrbPose, finish: FinishPhase) {
+ const L = clamp(S.listen, 0, 1.25);
+ return (
+ 1 +
+ 0.015 * S.breath * (1 - S.think) +
+ 0.025 * L +
+ 0.04 * S.done +
+ 0.06 * finish.pop
+ );
+}
+
+function eyePath(ctx: Ctx, w: number, h: number, smile: number) {
+ const rx = (w / 2) * (1 + 0.12 * smile);
+ const ry = h / 2;
+ const e = 0.32 * ry * smile;
+ const top = -ry;
+ const bot = lerp(ry, -0.36 * ry, smile);
+ const ct = (top - 0.25 * e) / 0.75;
+ const cb = (bot - 0.25 * e) / 0.75;
+ ctx.beginPath();
+ ctx.moveTo(-rx, e);
+ ctx.bezierCurveTo(-rx, ct, rx, ct, rx, e);
+ ctx.bezierCurveTo(rx, cb, -rx, cb, -rx, e);
+ ctx.closePath();
+}
+
+/** How long an orb stays perked up after the pointer comes close, in seconds. */
+const PERK_S = 1.6;
+
+/** The sphere: the mesh turned toward where it looks, its rim, and two eye-lights that blink and smile. */
+function sphere(
+ ctx: Ctx,
+ R: number,
+ S: OrbPose,
+ pal: Palette,
+ look: OrbLook,
+ dark: boolean,
+ size: number,
+) {
+ const na = S.noticeAge;
+ const perk =
+ na >= 0 && na < PERK_S ? Math.exp(-2.6 * na) * smooth(na / 0.1) : 0;
+ const finish = finishPhase(S.finishAge);
+ const sc = scaleOf(S, finish);
+ ctx.save();
+ ctx.translate(0, S.nod * R * 0.11);
+ ctx.scale(sc, sc);
+ circle(ctx, R);
+ ctx.clip();
+ const yaw = clamp(S.ax, -1.1, 1.1) * 0.62;
+ const pitch = -clamp(S.ay, -1.1, 1.1) * 0.5;
+ const roll = S.tilt;
+ mesh(ctx, R, S, pal, look.turn, Math.sin(yaw) * 0.2, -Math.sin(pitch) * 0.16);
+ light(ctx, -0.42 * R, -0.5 * R, 0.5 * R, 0.26);
+ light(ctx, 0, R * 0.05, R * 0.9, 0.28 * S.done);
+ rim(ctx, R, pal.shade, dark);
+ const small = size < 40;
+ const widen = 1 + 0.16 * perk;
+ const open = clamp(1 - 0.94 * S.blink) * (1 - 0.3 * S.think) * widen;
+ const cr = Math.cos(roll);
+ const sr = Math.sin(roll);
+ const ew = Math.max(small ? 2.4 : 1.8, R * 0.16 * widen);
+ const eh = Math.max(small ? 3.4 : 2.4, R * 0.3);
+ for (const side of [-1, 1]) {
+ if (finish.eyes <= 0.003) break;
+ const lon = side * 0.35 * look.eyes;
+ const lat = 0.05 + 0.04 * S.smile;
+ const x = Math.sin(lon) * Math.cos(lat);
+ const y = Math.sin(lat);
+ const z = Math.cos(lon) * Math.cos(lat);
+ const y1 = y * Math.cos(pitch) + z * Math.sin(pitch);
+ const z1 = -y * Math.sin(pitch) + z * Math.cos(pitch);
+ const x2 = x * Math.cos(yaw) + z1 * Math.sin(yaw);
+ const sx = x2 * cr - y1 * sr;
+ const sy = x2 * sr + y1 * cr;
+ const fx = Math.sqrt(clamp(1 - sx * sx, 0.12, 1));
+ const fy = Math.sqrt(clamp(1 - sy * sy, 0.2, 1));
+ const h = Math.max(small ? 1.1 : 0.9, eh * open * fy);
+ ctx.save();
+ ctx.globalAlpha = finish.eyes;
+ ctx.translate(sx * R * 0.84, -sy * R * 0.84 + (eh - h) * 0.28);
+ ctx.rotate(-roll);
+ ctx.shadowColor = dark ? "rgba(8,6,20,.4)" : "rgba(20,16,33,.28)";
+ ctx.shadowBlur = Math.max(1, R * 0.07);
+ ctx.fillStyle = "rgba(255,255,255,.97)";
+ eyePath(ctx, ew * fx, h, clamp(S.smile) * (1 - S.blink));
+ ctx.fill();
+ ctx.restore();
+ }
+ check(ctx, R, finish.check, finish.checkAlpha, dark);
+ ctx.restore();
+}
+
+/**
+ * The whole orb with `look`, centred on the context's origin, for a footprint of `size` CSS px.
+ * Its glow, the landing's ping and a finish's rays spill past the footprint, so draw it on a canvas
+ * twice its size.
+ */
+export function drawBotOrb(
+ ctx: Ctx,
+ size: number,
+ S: OrbPose,
+ { dark, look }: { dark: boolean; look: OrbLook },
+) {
+ const finish = finishPhase(S.finishAge);
+ const pal = palette(
+ look.palette,
+ 0.9 + 0.14 * clamp(S.listen) + 0.08 * S.think + 0.12 * clamp(S.done),
+ look.hue - 6 * S.think,
+ );
+ const r = (size / 2) * 0.72;
+ const sc = scaleOf(S, finish);
+ const glowAmt = clamp(
+ 0.18 +
+ 0.42 * clamp(S.listen) +
+ 0.22 * S.think +
+ 0.55 * S.done +
+ 0.45 * finish.pop,
+ );
+ glow(ctx, r * sc, glowAmt * 0.85, mix(pal.main, pal.deep, 0.4), dark);
+ ping(ctx, r, S.doneAge, pal, dark);
+ rays(ctx, r, finish.rays, pal, dark);
+ sphere(ctx, r, S, pal, look, dark, size * 0.72);
+}
+
+/** How long each age in a pose shows, in seconds: after that the orb draws as if it never happened. */
+const SHOWS_FOR: Partial> = {
+ doneAge: PING_S,
+ noticeAge: PERK_S,
+ finishAge: FINISH_S,
+};
+
+/**
+ * Whether two poses draw the same picture, every value within a thousandth of the other's. An age
+ * counts only while what it times still shows, so an orb that has settled poses the same picture
+ * frame after frame and need not draw it again.
+ */
+export function samePicture(a: OrbPose, b: OrbPose): boolean {
+ for (const key of Object.keys(a) as (keyof OrbPose)[]) {
+ const shows = SHOWS_FOR[key];
+ const seen = (v: number) =>
+ shows === undefined || (v >= 0 && v < shows) ? v : -1;
+ if (Math.abs(seen(a[key]) - seen(b[key])) >= 1e-3) return false;
+ }
+ return true;
+}
diff --git a/app/src/components/agents/orb/bot-orb.tsx b/app/src/components/agents/orb/bot-orb.tsx
new file mode 100644
index 0000000000..d628b9cd45
--- /dev/null
+++ b/app/src/components/agents/orb/bot-orb.tsx
@@ -0,0 +1,542 @@
+import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
+import { cn } from "@/lib/utils";
+import {
+ blinkCurve,
+ clamp,
+ drawBotOrb,
+ FINISH_HELD,
+ FINISH_S,
+ mulberry32,
+ nodCurve,
+ OPENBOT_LOOK,
+ type OrbLook,
+ type OrbPose,
+ orbLookFor,
+ restLook,
+ samePicture,
+ type Track,
+ type TrackKey,
+ track,
+} from "./bot-orb-draw";
+import { pointer, subscribeFrame, subscribePointer } from "./orb-clock";
+
+/**
+ * What the orb is doing: resting, listening to you type, working, or landing a finished thing.
+ * `finished` is a moment rather than a state: something the person allowed is done, and the orb
+ * plays a check with a burst of rays once, over whatever it was doing, which carries on under it.
+ * Hold it for `FINISH_MS`, then pass the state it was in before.
+ */
+export type BotOrbState =
+ | "idle"
+ | "listening"
+ | "thinking"
+ | "done"
+ | "finished";
+
+/** How long a finish plays: the check, its rays, and the eyes coming back. */
+export const FINISH_MS = FINISH_S * 1000;
+
+type Look = readonly [number, number];
+const LOOK_AT_YOU: Look = [0, 0.04];
+/** Up and aside, as if thinking it over. */
+const LOOK_UP: Look = [-0.36, -0.5];
+/** Down at what you are typing. */
+const LOOK_DOWN: Look = [0.1, 0.6];
+const LOOKS: Record = {
+ idle: LOOK_AT_YOU,
+ listening: LOOK_DOWN,
+ thinking: LOOK_UP,
+ done: LOOK_AT_YOU,
+ finished: LOOK_AT_YOU,
+};
+/** Seconds between blinks, before each one's jitter. */
+const BLINK_EVERY = 4.2;
+/** How far the canvas draws past the footprint on every side, as a share of the size. */
+const SPILL = 0.5;
+/** Backing pixels per CSS pixel, at most: sharp on a 3× screen without a huge canvas. */
+const MAX_DPR = 3;
+/** Below this footprint, in CSS px, an orb only blinks and watches: no glances, no breath, no drift. */
+const SMALL = 40;
+/**
+ * The least time between two frames of a small orb, in ms: about thirty a second, plenty for eyes a
+ * few px across, where a fast screen would otherwise draw every face in a list 120 times a second.
+ */
+const SMALL_FRAME_MS = 30;
+/** The moment on its clock a still is composed at, and where a small orb's colour holds. */
+const STILL_T = 1.6;
+/** How much of its look an orb gives the pointer: all of it at rest, some while it listens or works. */
+const WATCH: Record = {
+ idle: 1,
+ listening: 0.5,
+ thinking: 0.35,
+ done: 1,
+ finished: 1,
+};
+/** The pointer perks it up within this share of its size from its centre… */
+const NOTICE_NEAR = 0.9;
+/** …and has to go past this one before it can perk it up again. */
+const NOTICE_FAR = 1.3;
+
+/** Every track the state drives, and when the things that play once last happened. */
+type Motion = {
+ listen: Track;
+ think: Track;
+ done: Track;
+ smile: Track;
+ ax: Track;
+ ay: Track;
+ doneAt: number;
+ noticedAt: number;
+ finishedAt: number;
+ nods: number[];
+};
+
+const still = (v: number): Track => ({ from: v, keys: [] });
+
+/** The motion at rest in `state`, as it is on the first frame. */
+function settled(state: BotOrbState): Motion {
+ const look = LOOKS[state];
+ return {
+ listen: still(state === "listening" ? 1 : 0),
+ think: still(state === "thinking" ? 1 : 0),
+ done: still(0),
+ smile: still(state === "done" ? 1 : 0),
+ ax: still(look[0]),
+ ay: still(look[1]),
+ doneAt: -1,
+ noticedAt: -1,
+ finishedAt: state === "finished" ? 0 : -1,
+ nods: [],
+ };
+}
+
+/** The track restarted from its value at `now`, heading for the new key. */
+const retarget = (tr: Track, now: number, key: TrackKey): Track => ({
+ from: track(tr, now),
+ keys: [key],
+});
+
+/** Moves every track toward `state` from time `now`, each on its own spring or ease. */
+function enter(m: Motion, state: BotOrbState, now: number): Motion {
+ const look = LOOKS[state];
+ const gaze =
+ state === "thinking"
+ ? ([6.5, 0.85] as const)
+ : state === "listening"
+ ? ([15, 0.6] as const)
+ : ([12, 0.8] as const);
+ const next: Motion = {
+ ...m,
+ listen: retarget(
+ m.listen,
+ now,
+ state === "listening"
+ ? { at: now, to: 1, spring: [14, 0.5] }
+ : { at: now, to: 0, ease: 0.45 },
+ ),
+ think: retarget(m.think, now, {
+ at: now,
+ to: state === "thinking" ? 1 : 0,
+ ease: state === "thinking" ? 0.4 : 0.45,
+ }),
+ smile: retarget(m.smile, now, {
+ at: now + (state === "done" ? 0.08 : 0),
+ to: state === "done" ? 1 : 0,
+ ease: state === "done" ? 0.4 : 0.8,
+ }),
+ ax: retarget(m.ax, now, { at: now, to: look[0], spring: gaze }),
+ ay: retarget(m.ay, now, { at: now, to: look[1], spring: gaze }),
+ done: retarget(m.done, now, { at: now, to: 0, ease: 0.45 }),
+ };
+ if (state === "listening") next.noticedAt = now;
+ if (state === "done") {
+ // It lands: a glow that springs up and settles away, a ping and a nod.
+ next.done = {
+ from: track(m.done, now),
+ keys: [
+ { at: now, to: 1, spring: [11, 0.42] },
+ { at: now + 1.4, to: 0, ease: 1.2 },
+ ],
+ };
+ next.doneAt = now;
+ next.nods = [now + 0.1];
+ }
+ return next;
+}
+
+/**
+ * The pose at `t` seconds on the orb's clock. `seed` keeps two orbs from blinking and glancing in
+ * step. A small orb keeps still apart from its blinks, its colour included, so a list of them
+ * doesn't fidget and, once its eyes settle, has nothing new to draw.
+ */
+function poseAt(
+ m: Motion,
+ t: number,
+ seed: number,
+ { reduced, small }: { reduced: boolean; small: boolean },
+): OrbPose {
+ const listen = track(m.listen, t);
+ const think = clamp(track(m.think, t));
+ // Left alone it glances about now and then; while it listens or works it keeps its eyes on that.
+ const calm = reduced || small ? 0 : (1 - clamp(listen)) * (1 - think);
+ const glance = restLook(t, 3.6, seed);
+ let blink = 0;
+ if (!reduced) {
+ const k = Math.floor(t / BLINK_EVERY);
+ for (const n of [k - 1, k]) {
+ const at = n * BLINK_EVERY + 1.3 + mulberry32(seed + n)() * 1.6;
+ blink = Math.max(blink, blinkCurve(t - at));
+ }
+ }
+ return {
+ ta: small ? STILL_T : t,
+ listen,
+ think,
+ done: Math.max(0, track(m.done, t)),
+ smile: clamp(track(m.smile, t)),
+ tilt: 0.5 * glance[2] * calm,
+ ax: track(m.ax, t) + 0.6 * glance[0] * calm,
+ ay: track(m.ay, t) + 0.6 * glance[1] * calm,
+ blink,
+ nod: m.nods.reduce((n, at) => n + nodCurve(t - at), 0),
+ doneAge: m.doneAt < 0 ? -1 : t - m.doneAt,
+ noticeAge: m.noticedAt < 0 ? -1 : t - m.noticedAt,
+ // Still, a finish is its check without the burst.
+ finishAge: m.finishedAt < 0 ? -1 : reduced ? FINISH_HELD : t - m.finishedAt,
+ breath: small ? 0 : Math.sin((Math.PI * 2 * t) / 5.5),
+ };
+}
+
+/**
+ * An orb watching the pointer: the look it is heading for (`tx`, `ty`), where its eyes are on a
+ * spring (`x`, `y`, with their speeds), how much of its look it gives the pointer (`w`), whether it
+ * can see the pointer at all, and whether the pointer is close enough to have perked it up. `epoch`
+ * and `last` are the pointer's epoch and the time when it last looked.
+ */
+export type Gaze = {
+ tx: number;
+ ty: number;
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ w: number;
+ seen: boolean;
+ near: boolean;
+ epoch: number;
+ last: number;
+};
+
+export const restingGaze = (): Gaze => ({
+ tx: 0,
+ ty: 0,
+ x: 0,
+ y: 0,
+ vx: 0,
+ vy: 0,
+ w: 0,
+ seen: false,
+ near: false,
+ epoch: -1,
+ last: -1,
+});
+
+type Box = Pick;
+
+/**
+ * Moves `gaze` on to time `t` for an orb in `state`, measuring where the orb is on screen only when
+ * the pointer, the scroll or the window has changed. True on the frame the pointer comes close,
+ * which perks the orb up; a listening orb has already noticed you, so it never perks.
+ */
+export function followPointer(
+ gaze: Gaze,
+ t: number,
+ state: BotOrbState,
+ measure: () => Box,
+): boolean {
+ const dt = gaze.last < 0 ? 0 : Math.min(1 / 30, Math.max(0, t - gaze.last));
+ gaze.last = t;
+ let perked = false;
+ if (gaze.epoch !== pointer.epoch) {
+ gaze.epoch = pointer.epoch;
+ const box = measure();
+ const dx = pointer.x - (box.left + box.width / 2);
+ const dy = pointer.y - (box.top + box.height / 2);
+ const d = Math.hypot(dx, dy) || 1;
+ // Right over it, it looks straight out at you; the further away, the further round it turns.
+ const reach = d / (d + Math.max(48, box.width * 1.6));
+ gaze.tx = (dx / d) * reach * 0.95;
+ gaze.ty = (dy / d) * reach;
+ gaze.seen = pointer.on && box.width > 0;
+ const near =
+ gaze.seen && d < box.width * (gaze.near ? NOTICE_FAR : NOTICE_NEAR);
+ perked = near && !gaze.near && state !== "listening";
+ gaze.near = near;
+ }
+ gaze.vx += ((gaze.tx - gaze.x) * 150 - gaze.vx * 19) * dt;
+ gaze.vy += ((gaze.ty - gaze.y) * 150 - gaze.vy * 19) * dt;
+ gaze.x += gaze.vx * dt;
+ gaze.y += gaze.vy * dt;
+ gaze.w += ((gaze.seen ? WATCH[state] : 0) - gaze.w) * (1 - Math.exp(-dt * 5));
+ return perked;
+}
+
+/** Its own look, glances and all, gives way to the pointer by as much as it is watching. */
+function lookAt(pose: OrbPose, gaze: Gaze): OrbPose {
+ return {
+ ...pose,
+ ax: pose.ax + (gaze.x - pose.ax) * gaze.w,
+ ay: pose.ay + (gaze.y - pose.ay) * gaze.w,
+ tilt: pose.tilt + (-0.12 * gaze.x - pose.tilt) * gaze.w,
+ };
+}
+
+/**
+ * Sizes the canvas for a footprint of `size` and returns how to draw a pose on it with `look`, or
+ * null where there is no 2D context to draw with. A pose that would draw what the canvas already
+ * shows, in the same theme and at the same density, is skipped.
+ */
+function surface(canvas: HTMLCanvasElement, size: number, look: OrbLook) {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+ const box = size * (1 + 2 * SPILL);
+ let dpr = 0;
+ let shown: { pose: OrbPose; dark: boolean } | null = null;
+ return (pose: OrbPose) => {
+ // Read on every draw: a window dragged to another screen, or a zoom, changes it under an orb
+ // that stays mounted, and an orb drawn for the old one goes soft.
+ const next = Math.min(MAX_DPR, window.devicePixelRatio || 1);
+ const dark = document.documentElement.classList.contains("dark");
+ if (next === dpr && shown?.dark === dark && samePicture(shown.pose, pose)) {
+ return;
+ }
+ shown = { pose, dark };
+ if (next !== dpr) {
+ dpr = next;
+ canvas.width = Math.round(box * dpr);
+ canvas.height = Math.round(box * dpr);
+ }
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, box, box);
+ ctx.translate(box / 2, box / 2);
+ drawBotOrb(ctx, size, pose, { dark, look });
+ };
+}
+
+/**
+ * Calls `onChange` whenever the screen's pixel density changes, for a still orb that nothing else
+ * redraws. A resolution query matches only the density it names, so it is asked again after each change.
+ */
+function watchResolution(onChange: () => void): () => void {
+ if (typeof window.matchMedia !== "function") return () => {};
+ let query: MediaQueryList | null = null;
+ const arm = () => {
+ query?.removeEventListener("change", fire);
+ query = window.matchMedia(
+ `(resolution: ${window.devicePixelRatio || 1}dppx)`,
+ );
+ query.addEventListener("change", fire);
+ };
+ const fire = () => {
+ arm();
+ onChange();
+ };
+ arm();
+ return () => query?.removeEventListener("change", fire);
+}
+
+const REDUCE_MOTION = "(prefers-reduced-motion: reduce)";
+
+/**
+ * Whether the person asks for less motion, kept current: turning it on while the app is open stills
+ * every orb already on screen, not only the ones drawn afterwards.
+ */
+function usePrefersReducedMotion(): boolean {
+ return useSyncExternalStore(
+ (onChange) => {
+ if (typeof window.matchMedia !== "function") return () => {};
+ const query = window.matchMedia(REDUCE_MOTION);
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+ },
+ () =>
+ typeof window.matchMedia === "function" &&
+ window.matchMedia(REDUCE_MOTION).matches,
+ () => false,
+ );
+}
+
+let staggers = 0;
+const secondsSince = (start: number) => (performance.now() - start) / 1000;
+
+/**
+ * A Bot's orb, with the look its `seed` picks; with no seed, OpenBot's own, in violet. `size` is its
+ * footprint in CSS px; its glow spills past it. It breathes and glances about when idle, watches
+ * what you type when listening, swirls while thinking, and lands with a ping and a smile when done.
+ * Passed `finished`, its eyes give way to a check while short rays burst out round it, and then it
+ * is back as it was. Wherever the pointer is, its eyes follow it: fully at rest, partly while it
+ * listens or works, with a small perk when the pointer comes close. An orb under 40 px only blinks
+ * and watches, at about thirty frames a second.
+ *
+ * Every orb on the page draws on one shared animation frame, and only while it is on screen, the
+ * tab is shown and its picture has changed. With reduced motion, or `still`, it is a still in the
+ * state's pose, drawn again only when the state or the theme changes, and its eyes stay put.
+ */
+export function BotOrb({
+ className,
+ seed,
+ size,
+ state = "idle",
+ still = false,
+}: {
+ className?: string;
+ seed?: string;
+ size: number;
+ state?: BotOrbState;
+ still?: boolean;
+}) {
+ const boxRef = useRef(null);
+ const canvasRef = useRef(null);
+ const reduced = usePrefersReducedMotion() || still;
+ const look = useMemo(
+ () => (seed === undefined ? OPENBOT_LOOK : orbLookFor(seed)),
+ [seed],
+ );
+ // Each orb's own, so two orbs on a page, even two of one Bot, don't blink or glance in step.
+ const clock = useRef<{ start: number; stagger: number } | null>(null);
+ clock.current ??= { start: performance.now(), stagger: ++staggers * 7 };
+ const motion = useRef(null);
+ const shown = useRef(null);
+ const current = useRef(state);
+ const gaze = useRef(null);
+ gaze.current ??= restingGaze();
+
+ // It starts at rest in its first state, except that a first "done" still lands. After that a
+ // change moves every track from where it is, so a change mid-motion never jumps. A finish moves
+ // no track: it plays over the state it was in, and coming back to that state changes nothing.
+ useEffect(() => {
+ current.current = state;
+ if (!clock.current) return;
+ const t = secondsSince(clock.current.start);
+ if (state === "finished") {
+ motion.current = {
+ ...(motion.current ?? settled("idle")),
+ finishedAt: t,
+ };
+ return;
+ }
+ if (shown.current === state) return;
+ const before = motion.current;
+ motion.current =
+ shown.current === null && state !== "done"
+ ? { ...settled(state), finishedAt: before?.finishedAt ?? -1 }
+ : enter(before ?? settled("idle"), state, t);
+ shown.current = state;
+ }, [state]);
+
+ // A still: a composed picture in the state's pose, eyes open, its look held, no ping; finished,
+ // the check in place of the eyes, with no burst, for as long as it is held. Nothing draws it
+ // again on its own, so it is redrawn when the theme flips the root's `dark` class.
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const draw = reduced && canvas ? surface(canvas, size, look) : null;
+ if (!draw || !clock.current) return;
+ const { stagger } = clock.current;
+ const held =
+ state === "finished"
+ ? { ...settled(shown.current ?? "idle"), finishedAt: 0 }
+ : settled(state);
+ const paint = () =>
+ draw(
+ poseAt(held, STILL_T, stagger, { reduced: true, small: size < SMALL }),
+ );
+ paint();
+ const theme = new MutationObserver(paint);
+ theme.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+ const unwatch = watchResolution(paint);
+ return () => {
+ theme.disconnect();
+ unwatch();
+ };
+ }, [reduced, size, look, state]);
+
+ // Alive: drawn on the shared clock while it is on screen. It counts as on screen until an
+ // IntersectionObserver says otherwise, and always where there is none to ask. It reads the theme
+ // on every frame, so a flip shows on the next one; a frame that would draw what is already there
+ // is skipped, so a small orb at rest with the pointer still draws nothing between blinks.
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const box = boxRef.current;
+ const draw = !reduced && canvas ? surface(canvas, size, look) : null;
+ if (!draw || !box || !clock.current || !gaze.current) return;
+ const { start, stagger } = clock.current;
+ const eyes = gaze.current;
+ const small = size < SMALL;
+ const measure = () => box.getBoundingClientRect();
+ let ticked = Number.NEGATIVE_INFINITY;
+ const frame = (now: number) => {
+ if (small && now - ticked < SMALL_FRAME_MS) return;
+ ticked = now;
+ const t = Math.max(0, (now - start) / 1000);
+ const state = current.current;
+ if (followPointer(eyes, t, state, measure) && motion.current) {
+ motion.current = { ...motion.current, noticedAt: t };
+ }
+ const pose = poseAt(motion.current ?? settled(state), t, stagger, {
+ reduced: false,
+ small,
+ });
+ draw(lookAt(pose, eyes));
+ };
+ let unsubscribe: (() => void) | null = null;
+ const onScreen = (on: boolean) => {
+ if (on && !unsubscribe) unsubscribe = subscribeFrame(frame);
+ if (!on && unsubscribe) {
+ unsubscribe();
+ unsubscribe = null;
+ }
+ };
+ const sight =
+ "IntersectionObserver" in window
+ ? new IntersectionObserver((entries) =>
+ // The newest entry is where it is now.
+ onScreen(entries.at(-1)?.isIntersecting ?? false),
+ )
+ : null;
+ sight?.observe(box);
+ onScreen(true);
+ const unwatch = subscribePointer();
+ return () => {
+ sight?.disconnect();
+ onScreen(false);
+ unwatch();
+ };
+ }, [reduced, size, look]);
+
+ const spill = size * SPILL;
+ return (
+
+
+
+ );
+}
diff --git a/app/src/components/agents/orb/orb-clock.ts b/app/src/components/agents/orb/orb-clock.ts
new file mode 100644
index 0000000000..176196bbfb
--- /dev/null
+++ b/app/src/components/agents/orb/orb-clock.ts
@@ -0,0 +1,140 @@
+/**
+ * What every orb on the page shares, so a list of fifty Bots costs one animation loop and one set of
+ * listeners rather than fifty of each: a clock that asks the browser for one frame and draws every
+ * orb in it, and where the pointer is, for the orbs whose eyes follow it.
+ *
+ * Both start with their first subscriber and stop with their last, so nothing is left running or
+ * listening on a page with no orb on it.
+ */
+
+type FrameCallback = (now: number) => void;
+
+const subscribers = new Set();
+let frame: number | null = null;
+/** The document whose visibility the clock follows, while it has subscribers. */
+let watched: Document | null = null;
+
+function run(now: number) {
+ frame = null;
+ // A Set visits what is added during the loop and skips what is removed, so an orb that unmounts
+ // mid-frame is not drawn after it has gone.
+ for (const callback of subscribers) callback(now);
+ schedule();
+}
+
+function schedule() {
+ if (frame !== null || subscribers.size === 0 || document.hidden) return;
+ frame = window.requestAnimationFrame(run);
+}
+
+function halt() {
+ if (frame !== null) window.cancelAnimationFrame(frame);
+ frame = null;
+}
+
+/** A hidden tab draws nothing, and picks up again where its orbs are now once it is shown. */
+function onVisibility() {
+ if (document.hidden) halt();
+ else schedule();
+}
+
+/**
+ * Calls `callback` once on every animation frame, with the frame's timestamp, until the returned
+ * function is called. Every subscriber is drawn in the same frame; no frame is asked for while the
+ * tab is hidden or nobody is subscribed.
+ */
+export function subscribeFrame(callback: FrameCallback): () => void {
+ subscribers.add(callback);
+ if (!watched) {
+ watched = document;
+ watched.addEventListener("visibilitychange", onVisibility);
+ }
+ schedule();
+ return () => {
+ if (!subscribers.delete(callback) || subscribers.size > 0) return;
+ halt();
+ watched?.removeEventListener("visibilitychange", onVisibility);
+ watched = null;
+ };
+}
+
+// ---------- the pointer ----------
+
+type PointerState = { x: number; y: number; on: boolean; epoch: number };
+
+const state: PointerState = { x: 0, y: 0, on: false, epoch: 0 };
+
+/**
+ * Where the pointer is, in client px, and whether it is on the page at all. `epoch` counts every
+ * move, scroll and resize, so an orb measures itself again only when something has changed. Kept
+ * up to date only while something is subscribed with `subscribePointer`.
+ */
+export const pointer: Readonly = state;
+
+let watchers = 0;
+let lift: ReturnType | undefined;
+let listening: Window | null = null;
+
+const aim = (event: PointerEvent) => {
+ state.x = event.clientX;
+ state.y = event.clientY;
+ state.on = true;
+ state.epoch++;
+ clearTimeout(lift);
+};
+const away = () => {
+ state.on = false;
+ state.epoch++;
+};
+// A finger lifts off the glass; a mouse stays where it was. Give a tap a moment of being watched.
+const lifted = (event: PointerEvent) => {
+ if (event.pointerType !== "mouse") lift = setTimeout(away, 1600);
+};
+const left = (event: PointerEvent) => {
+ if (!event.relatedTarget) away();
+};
+const moved = () => {
+ state.epoch++;
+};
+
+function listen(target: Window) {
+ target.addEventListener("pointermove", aim, { passive: true });
+ target.addEventListener("pointerdown", aim, { passive: true });
+ target.addEventListener("pointerup", lifted, { passive: true });
+ target.document.addEventListener("pointerout", left, { passive: true });
+ target.addEventListener("blur", away);
+ target.addEventListener("scroll", moved, { capture: true, passive: true });
+ target.addEventListener("resize", moved);
+}
+
+function unlisten(target: Window) {
+ target.removeEventListener("pointermove", aim);
+ target.removeEventListener("pointerdown", aim);
+ target.removeEventListener("pointerup", lifted);
+ target.document.removeEventListener("pointerout", left);
+ target.removeEventListener("blur", away);
+ target.removeEventListener("scroll", moved, { capture: true });
+ target.removeEventListener("resize", moved);
+ clearTimeout(lift);
+ // Nobody is following it now, so what it last knew is stale for whoever subscribes next.
+ away();
+}
+
+/**
+ * Keeps `pointer` up to date until the returned function is called: one set of window listeners for
+ * every orb that watches.
+ */
+export function subscribePointer(): () => void {
+ if (watchers++ === 0) {
+ listening = window;
+ listen(listening);
+ }
+ let subscribed = true;
+ return () => {
+ if (!subscribed) return;
+ subscribed = false;
+ if (--watchers > 0 || !listening) return;
+ unlisten(listening);
+ listening = null;
+ };
+}
diff --git a/app/src/components/bot-profile/profile.tsx b/app/src/components/bot-profile/profile.tsx
index fef082c983..43e5282327 100644
--- a/app/src/components/bot-profile/profile.tsx
+++ b/app/src/components/bot-profile/profile.tsx
@@ -7,7 +7,6 @@ import {
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import type * as React from "react";
-import { useState } from "react";
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import { BotNeedsYou } from "@/components/approvals/waiting";
import { PageRows, PageSection } from "@/components/layout/page-shell";
@@ -40,6 +39,10 @@ import {
type BotNotify,
botLifecycleQueryOptions,
} from "@/lib/bot-lifecycle/queries";
+import {
+ requestNotificationPermission,
+ useNotificationPermission,
+} from "@/lib/notifications/permission";
import { queryClient } from "@/query-client";
import { BotActivityGlance } from "./activity";
import { BotPausedBanner } from "./pause-banner";
@@ -65,15 +68,8 @@ export function BotProfile({
const lifecycle = useQuery(botLifecycleQueryOptions(agent.id));
const pause = useMutation(setBotPausedMutationOptions(queryClient));
const notify = useMutation(setBotNotifyMutationOptions(queryClient));
- const [permission, setPermission] = useState(() => {
- try {
- return typeof Notification === "undefined"
- ? "unsupported"
- : Notification.permission;
- } catch {
- return "unsupported";
- }
- });
+ // The browser's answer, shared with onboarding's Get notified step, so a yes there shows here.
+ const permission = useNotificationPermission();
return (
<>
@@ -186,13 +182,7 @@ export function BotProfile({