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..b7c02f85dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -78,6 +78,19 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
`COMPUTER_MEMORY_HIGH_PERCENT=0` and `COMPUTER_TAB_IDLE_MS=0` (Helm: `computers.memoryHighPercent`
and `computers.tabIdleMs`) keep the old behaviour.
+### Choose how a Bot looks
+
+A Bot's Setup page has a Look section for whoever may edit the Bot: its face, large, the eight
+colours to pick from, and New face, which keeps the colour and draws a different face. A choice
+saves at once and shows wherever the Bot appears, the conversation list and a conversation's header
+included. The choice is the Bot's avatar seed, so nothing is migrated: `PATCH /api/agents/:agentId`
+takes an optional `avatarSeed`, and a seed may start with a colour, as `moss:knowledge`. A seed that
+names no colour, which is every Bot's until somebody chooses, draws exactly as before. Violet stays
+OpenBot's own, and a `violet:` seed or a colour no Bot has is refused. A tenant package's
+`avatar_seed` may name a colour the same way. A new look is named on the Bot's `bot.updated` audit
+row. `GET /api/agents` now lists Bots in the order they were made, so restyling or renaming a Bot no
+longer moves it to the end of the list.
+
### A computer closes browsers nobody is using when its memory runs high
A computer runs one browser for each Bot that is browsing, and the only limits were a count and half
@@ -133,6 +146,18 @@ 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.
+### 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..127b2a7f32
--- /dev/null
+++ b/app/src/components/agents/orb/bot-orb-draw.ts
@@ -0,0 +1,795 @@
+import {
+ BOT_COLOURS,
+ type BotColour,
+ namedColour,
+} from "@openbot/shared/bot-look";
+
+/**
+ * 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, unless the seed names its colour; 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 can be, which are `BOT_COLOURS`. A
+ * Bot's look is picked from those by their order there, not by their order here.
+ */
+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<"violet" | BotColour, PaletteSpec>;
+
+export type OrbPaletteName = keyof typeof PALETTES;
+
+/** Every colour, OpenBot's violet first. */
+export const ORB_PALETTES = Object.keys(PALETTES) as readonly OrbPaletteName[];
+
+/**
+ * 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 colour a Bot's seed is drawn in: the one it names, or else the one its hash picks. */
+export function botColourFor(seed: string): BotColour {
+ return (
+ namedColour(seed) ??
+ (BOT_COLOURS[hashSeed(seed) % BOT_COLOURS.length] as BotColour)
+ );
+}
+
+/**
+ * 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.
+ * A seed that names its colour, as `moss:k2p9x7aq`, is drawn in it, and the hash of the whole seed
+ * still draws the rest. 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: botColourFor(seed),
+ 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/look.tsx b/app/src/components/bot-profile/look.tsx
new file mode 100644
index 0000000000..f3cba6f172
--- /dev/null
+++ b/app/src/components/bot-profile/look.tsx
@@ -0,0 +1,141 @@
+import {
+ BOT_COLOURS,
+ type BotColour,
+ newFace,
+ recoloured,
+ seedFor,
+} from "@openbot/shared/bot-look";
+import { useMutation } from "@tanstack/react-query";
+import { useId, useRef, useState } from "react";
+import { AbstractAvatar } from "@/components/agents/abstract-avatar";
+import { botColourFor, palette } from "@/components/agents/orb/bot-orb-draw";
+import { PageRows, PageSection } from "@/components/layout/page-shell";
+import { Button } from "@/components/ui/button";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemFooter,
+ ItemMedia,
+ ItemTitle,
+} from "@/components/ui/item";
+import { profileUpdateInput } from "@/lib/agents/form";
+import { updateAgentMutationOptions } from "@/lib/agents/mutations";
+import type { AgentProfile } from "@/lib/agents/queries";
+import { queryClient } from "@/query-client";
+
+/** Each colour as a person would say it. */
+const COLOUR_NAMES: Record = {
+ ember: "Orange",
+ moss: "Green",
+ sun: "Yellow",
+ berry: "Pink",
+ ocean: "Blue",
+ sky: "Light blue",
+ coral: "Coral",
+ slate: "Grey",
+};
+
+const rgb = (colour: readonly number[]) => `rgb(${colour.join(",")})`;
+
+/** A swatch's fill: the colour's sphere in small, pale where the light falls and deep at its rim. */
+function swatchFill(colour: BotColour): string {
+ const { pale, main, deep } = palette(colour, 1, 0);
+ return `radial-gradient(circle at 35% 30%, ${rgb(pale)}, ${rgb(main)} 55%, ${rgb(deep)})`;
+}
+
+/**
+ * How the Bot looks: its face, large and alive, the eight colours and a new face. A choice saves when
+ * it is made, as Visibility's does, and the face shows it at once; one that can't be saved goes
+ * back and says so. Drawn only for whoever may edit the Bot, the people who may also rename it.
+ *
+ * The colours are native radios, so the group is one stop for Tab and the arrow keys move through
+ * it, as they do in any radio group. They stay live while a choice saves, so arrowing through them
+ * keeps its place, and the newest choice wins.
+ */
+export function LookSection({ agent }: { agent: AgentProfile }) {
+ const update = useMutation(updateAgentMutationOptions(queryClient));
+ // The look just chosen, shown before the server has it. Cleared when the newest choice settles: by
+ // then a saved one is the Bot's own seed, since the save refetches the Bot before it resolves.
+ const [chosen, setChosen] = useState(null);
+ const [failed, setFailed] = useState(false);
+ const newest = useRef(0);
+ const group = useId();
+ const seed = chosen ?? agent.avatarSeed;
+ const colour = botColourFor(seed);
+
+ const choose = async (next: string) => {
+ const turn = ++newest.current;
+ setChosen(next);
+ setFailed(false);
+ try {
+ await update.mutateAsync({
+ agentId: agent.id,
+ input: { ...profileUpdateInput(agent, {}), avatarSeed: next },
+ });
+ } catch {
+ if (turn === newest.current) setFailed(true);
+ }
+ if (turn === newest.current) setChosen(null);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {COLOUR_NAMES[colour]}
+ {failed ? (
+
+ Couldn't save that look, so the last one is back.
+
+ ) : null}
+
+
+
+
+
+
+ {BOT_COLOURS.map((option) => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/app/src/components/bot-profile/setup.tsx b/app/src/components/bot-profile/setup.tsx
index 9d4d0687bf..16bc96b3e1 100644
--- a/app/src/components/bot-profile/setup.tsx
+++ b/app/src/components/bot-profile/setup.tsx
@@ -63,6 +63,7 @@ import {
import { isComposing } from "@/lib/composing";
import { myCapabilitiesQueryOptions } from "@/lib/enterprise/queries";
import { queryClient } from "@/query-client";
+import { LookSection } from "./look";
type TextField = "name" | "title" | "roleDescription";
@@ -84,7 +85,10 @@ const TEXT_FIELDS: {
},
];
-/** Who a Bot is and where it runs. Editable by whoever may manage it; read-only for everyone else. */
+/**
+ * Who a Bot is, how it looks and where it runs. Editable by whoever may manage it; read-only for
+ * everyone else, who don't see the Look section at all.
+ */
export function SetupSections({ agent }: { agent: AgentProfile }) {
const update = useMutation(updateAgentMutationOptions(queryClient));
const [editing, setEditing] = useState(null);
@@ -153,6 +157,8 @@ export function SetupSections({ agent }: { agent: AgentProfile }) {
) : null}
+ {/* Beside who it is, since its face is part of that. Only for whoever may change it. */}
+ {agent.canManage ? : null}
{open ? (
+
+ {seedOf ? (
+
+ ) : null}
+
) : (
-
+
);
return (
@@ -62,9 +71,11 @@ const LAYOUTS = {
*/
function GroupFaces({
participantIds,
+ seedOf,
size,
}: {
participantIds: string[];
+ seedOf: ((botId: string) => string) | null;
size: number;
}) {
const shown = participantIds.slice(0, 3);
@@ -97,7 +108,7 @@ function GroupFaces({
WebkitMaskComposite: "source-in",
}}
>
-
+ {seedOf ? : null}
);
})}
diff --git a/app/src/components/channels/composer/attachment-strip.tsx b/app/src/components/channels/composer/attachment-strip.tsx
index 90ba773333..b752533518 100644
--- a/app/src/components/channels/composer/attachment-strip.tsx
+++ b/app/src/components/channels/composer/attachment-strip.tsx
@@ -144,8 +144,8 @@ export function AttachmentStrip({
* ON THE SIZE LINE, NOT UNDER IT, AND THAT IS A LAYOUT CONSTRAINT RATHER THAN A
* PREFERENCE. The tile's height is fixed so that a file and a thumbnail read as
* one row. A third line does not fit: with three lines the two in-flow children
- * need 65px (a 20px icon and three 15px lines) inside a 62px box. Something
- * has to give, and the only choices are the icon shrinking to 17px or the text
+ * need 68px (a 20px icon and three 16px lines) inside a 62px box. Something
+ * has to give, and the only choices are the icon shrinking to 14px or the text
* spilling past the border. Folding the warning onto this line keeps two.
*
* `truncate` is the guarantee, not the wording: `formatBytes` is unbounded, so a
diff --git a/app/src/components/theme-provider.tsx b/app/src/components/theme-provider.tsx
index 9806bb8119..1a5fd3dbed 100644
--- a/app/src/components/theme-provider.tsx
+++ b/app/src/components/theme-provider.tsx
@@ -1,41 +1,87 @@
import {
createContext,
type ReactNode,
+ useCallback,
useContext,
useEffect,
useState,
} from "react";
import {
- applyDarkTheme,
- parseStoredDarkTheme,
+ applyTheme,
+ LEGACY_THEME_STORAGE_KEY,
+ parseThemePreference,
+ type ResolvedTheme,
+ resolveTheme,
+ SYSTEM_DARK_QUERY,
THEME_STORAGE_KEY,
+ type ThemePreference,
} from "@/lib/theme";
type ThemeContextValue = {
- dark: boolean;
- setDark: (dark: boolean) => void;
+ /** What the person chose: follow the system, or always light or dark. */
+ preference: ThemePreference;
+ /** What is drawn right now. */
+ resolved: ResolvedTheme;
+ setPreference: (preference: ThemePreference) => void;
};
const ThemeContext = createContext(null);
+/** Storage throws in some privacy modes; a theme is not worth taking the app down for. */
+function readStored(key: string): string | null {
+ try {
+ return window.localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
+
+function systemDarkNow(): boolean {
+ return window.matchMedia?.(SYSTEM_DARK_QUERY).matches ?? false;
+}
+
export function ThemeProvider({ children }: { children: ReactNode }) {
- const [dark, setDark] = useState(() =>
- parseStoredDarkTheme(window.localStorage.getItem(THEME_STORAGE_KEY)),
+ const [preference, setPreferenceState] = useState(() =>
+ parseThemePreference(
+ readStored(THEME_STORAGE_KEY),
+ readStored(LEGACY_THEME_STORAGE_KEY),
+ ),
);
+ const [systemDark, setSystemDark] = useState(systemDarkNow);
+
+ // The system's setting, live: it changes at sunset without anybody reloading.
+ useEffect(() => {
+ const query = window.matchMedia?.(SYSTEM_DARK_QUERY);
+ if (!query) return;
+ const follow = () => setSystemDark(query.matches);
+ follow();
+ query.addEventListener("change", follow);
+ return () => query.removeEventListener("change", follow);
+ }, []);
+ const resolved = resolveTheme(preference, systemDark);
useEffect(() => {
- applyDarkTheme(dark, {
- setStoredValue: (key, value) => window.localStorage.setItem(key, value),
+ applyTheme(resolved, {
toggleRootClass: (name, force) =>
document.documentElement.classList.toggle(name, force),
setRootColorScheme: (scheme) => {
document.documentElement.style.colorScheme = scheme;
},
});
- }, [dark]);
+ }, [resolved]);
+
+ // Written only when the person chooses, so following the system stays the default until they do.
+ const setPreference = useCallback((next: ThemePreference) => {
+ setPreferenceState(next);
+ try {
+ window.localStorage.setItem(THEME_STORAGE_KEY, next);
+ } catch {
+ // The choice holds for this visit; it just isn't remembered.
+ }
+ }, []);
return (
-
+
{children}
);
diff --git a/app/src/lib/agents/bot-seeds.ts b/app/src/lib/agents/bot-seeds.ts
new file mode 100644
index 0000000000..5c8d8c62ea
--- /dev/null
+++ b/app/src/lib/agents/bot-seeds.ts
@@ -0,0 +1,28 @@
+import { useQuery } from "@tanstack/react-query";
+import { useCallback } from "react";
+import { type AgentProfile, agentListQueryOptions } from "./queries";
+
+/** Stable query selector so the id-to-seed map only rebuilds when the roster changes. */
+const toSeeds = (agents: AgentProfile[]): Map =>
+ new Map(agents.map((agent) => [agent.id, agent.avatarSeed]));
+
+/**
+ * Each Bot's face by its id: the seed it is drawn from, which its owner may have changed, so a list
+ * that knows only ids still draws the face every other surface draws. A Bot this person's list
+ * doesn't hold, such as a hidden one, falls back to its id, which was its seed until somebody
+ * changed it. Null while the list is loading, so no face is drawn that is about to change.
+ */
+export function useBotSeeds(): ((botId: string) => string) | null {
+ const { data: seeds, isPending } = useQuery({
+ ...agentListQueryOptions(),
+ select: toSeeds,
+ // A face that mounts does not refetch a list already held: a sidebar mounts dozens. A changed
+ // look still arrives, because a save invalidates the list.
+ refetchOnMount: false,
+ });
+ const seedOf = useCallback(
+ (botId: string) => seeds?.get(botId) ?? botId,
+ [seeds],
+ );
+ return isPending ? null : seedOf;
+}
diff --git a/app/src/lib/agents/mutations.ts b/app/src/lib/agents/mutations.ts
index 5465de5c03..325faeefcc 100644
--- a/app/src/lib/agents/mutations.ts
+++ b/app/src/lib/agents/mutations.ts
@@ -21,6 +21,11 @@ export type AgentInput = {
* Bot between computers; only the Isolated control sends it.
*/
isolated?: boolean;
+ /**
+ * A new look, as a seed (`@openbot/shared/bot-look`). Only an edit sends it, and only the Look
+ * section; omitted leaves the Bot's face as it is.
+ */
+ avatarSeed?: string;
};
/** The sentence for every write here, since they all fail the same way to a reader. */
diff --git a/app/src/lib/theme.ts b/app/src/lib/theme.ts
index 59f663febd..1c613de78b 100644
--- a/app/src/lib/theme.ts
+++ b/app/src/lib/theme.ts
@@ -1,18 +1,66 @@
-export const THEME_STORAGE_KEY = "openbot-theme";
+/**
+ * Light, dark, or whatever the system is set to. The default is the system's setting, and it follows
+ * the system live, so a computer that turns dark at sunset turns OpenBot dark with it.
+ *
+ * `index.html` repeats `parseThemePreference` and `resolveTheme` inline, before the first paint, so the app never flashes
+ * the wrong theme while the bundle loads. The two read the same keys; a test holds them together.
+ */
-export function parseStoredDarkTheme(value: string | null) {
- return value === "dark";
+/** Where the person's choice is kept: "system", "light" or "dark". */
+export const THEME_STORAGE_KEY = "openbot-appearance";
+
+/**
+ * Where OpenBot kept its old switch, as "dark" or "light". It wrote "light" on every load whether or
+ * not anybody chose it, so only "dark" there means somebody asked for it; "light" there is the old
+ * default and now means "follow the system".
+ */
+export const LEGACY_THEME_STORAGE_KEY = "openbot-theme";
+
+/** The media query the system's setting arrives on. */
+export const SYSTEM_DARK_QUERY = "(prefers-color-scheme: dark)";
+
+export type ThemePreference = "system" | "light" | "dark";
+export type ResolvedTheme = "light" | "dark";
+
+export const THEME_PREFERENCES: readonly ThemePreference[] = [
+ "system",
+ "light",
+ "dark",
+];
+
+/**
+ * The person's choice, from what is stored under the two keys. Anything not a choice, including
+ * nothing at all, is "system".
+ */
+export function parseThemePreference(
+ stored: string | null,
+ legacy: string | null = null,
+): ThemePreference {
+ if (stored === "system" || stored === "light" || stored === "dark")
+ return stored;
+ return legacy === "dark" ? "dark" : "system";
+}
+
+/** The theme to draw: the choice, or the system's when the choice is to follow it. */
+export function resolveTheme(
+ preference: ThemePreference,
+ systemDark: boolean,
+): ResolvedTheme {
+ if (preference === "system") return systemDark ? "dark" : "light";
+ return preference;
}
type ThemeEffects = {
- setStoredValue: (key: string, value: string) => void;
toggleRootClass: (name: string, force: boolean) => void;
- setRootColorScheme: (scheme: "dark" | "light") => void;
+ setRootColorScheme: (scheme: ResolvedTheme) => void;
};
-export function applyDarkTheme(dark: boolean, effects: ThemeEffects) {
- effects.setStoredValue(THEME_STORAGE_KEY, dark ? "dark" : "light");
- effects.toggleRootClass("dark", dark);
- // `index.html` sets this inline before paint, and an inline style outranks the palette.
- effects.setRootColorScheme(dark ? "dark" : "light");
+/**
+ * Draw a theme: the `dark` class the tokens hang off, and the colour scheme the browser draws its
+ * own controls and scrollbars in. `index.html` sets the scheme inline before paint, and an inline
+ * style outranks the stylesheet, so this has to set it too.
+ */
+export function applyTheme(theme: ResolvedTheme, effects: ThemeEffects) {
+ effects.toggleRootClass("dark", theme === "dark");
+ effects.setRootColorScheme(theme);
}
diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts
index 365058cebd..ee2cf32355 100644
--- a/app/src/lib/utils.ts
+++ b/app/src/lib/utils.ts
@@ -1,5 +1,31 @@
import { type ClassValue, clsx } from "clsx";
-import { twMerge } from "tailwind-merge";
+import { extendTailwindMerge } from "tailwind-merge";
+
+/**
+ * The five sizes of the type scale (`theme.css`).
+ *
+ * Each sets a font size, a line height and tracking. `tailwind-merge` only knows Tailwind's own
+ * utilities, so without this it keeps `text-sm` beside `type-body` and the stylesheet's order picks
+ * `text-sm`. Registered as font sizes, a `type-*` given later replaces an earlier `text-*` size, and
+ * drops the `leading-*` that came with it.
+ */
+export const TYPE_SCALE_CLASSES = [
+ "type-display",
+ "type-nav",
+ "type-body",
+ "type-sub",
+ "type-caption",
+] as const;
+
+const twMerge = extendTailwindMerge({
+ extend: {
+ classGroups: {
+ "font-size": [
+ { type: TYPE_SCALE_CLASSES.map((name) => name.slice("type-".length)) },
+ ],
+ },
+ },
+});
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
diff --git a/app/src/routes/_authed/onboarding.tsx b/app/src/routes/_authed/onboarding.tsx
index 68d318767f..93a0f87072 100644
--- a/app/src/routes/_authed/onboarding.tsx
+++ b/app/src/routes/_authed/onboarding.tsx
@@ -1,10 +1,10 @@
import { WEB_HARNESSES } from "@openbot/shared/harnesses";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
-import Avatar from "boring-avatars";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import * as React from "react";
import useMeasure from "react-use-measure";
+import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import { HarnessMark } from "@/components/agents/harness-mark";
import AgentOrb from "@/components/agents/orb/agent-orb";
import { Composer } from "@/components/channels/composer";
@@ -146,7 +146,7 @@ function RosterStep() {
// Dimmed and labelled, so an invented name never reads as a Bot this deployment has.
className={`bg-card p-4 rounded-lg flex flex-row gap-4 items-center ${a.example ? "opacity-70" : ""}`}
>
-
+
{a.name}
diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx
index 9d52eb0016..c0c6febc3d 100644
--- a/app/src/routes/_authed/settings/index.tsx
+++ b/app/src/routes/_authed/settings/index.tsx
@@ -16,17 +16,30 @@ import {
ItemDescription,
ItemTitle,
} from "@/components/ui/item";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
-import { Switch } from "@/components/ui/switch";
import { formatHotkey, HOTKEYS } from "@/lib/hotkeys/hotkeys";
+import { THEME_PREFERENCES, type ThemePreference } from "@/lib/theme";
import { SkinPicker } from "@/skins/skin-picker";
export const Route = createFileRoute("/_authed/settings/")({
component: RouteComponent,
});
+const APPEARANCE: Record = {
+ system: "System",
+ light: "Light",
+ dark: "Dark",
+};
+
function RouteComponent() {
- const { dark, setDark } = useTheme();
+ const { preference, setPreference } = useTheme();
/*
* The measurements that used to be written out here now live in `PageShell`, which Skills, Admin
@@ -44,19 +57,32 @@ function RouteComponent() {
+ {/* Three choices, so a select like the message list's below, not a switch. */}
- Dark theme
+ Appearance
- Use the dark appearance across OpenBot.
+ System follows your device's light or dark setting.
-
+
diff --git a/app/src/skins/opendots/sidebar.tsx b/app/src/skins/opendots/sidebar.tsx
index 7011d53dca..22599a8ef4 100644
--- a/app/src/skins/opendots/sidebar.tsx
+++ b/app/src/skins/opendots/sidebar.tsx
@@ -207,8 +207,6 @@ function BotsSection({
className={cn(
navItem,
"min-h-[42px] gap-[9px] px-2.5 py-1.5 text-[12px] text-(--opendots-dot-ink) hover:bg-(--opendots-hover)",
- // The row's only drawing is the avatar, which keeps its own size.
- "[&_svg]:size-full",
active &&
"bg-(--opendots-active) hover:bg-(--opendots-active)",
)}
diff --git a/app/src/skins/openduet/header.tsx b/app/src/skins/openduet/header.tsx
index 85e81c0618..1880ffc8ad 100644
--- a/app/src/skins/openduet/header.tsx
+++ b/app/src/skins/openduet/header.tsx
@@ -9,7 +9,7 @@ import {
} from "@tanstack/react-router";
import { useEffect, useState } from "react";
import AgentOrb from "@/components/agents/orb/agent-orb";
-import type { AIState } from "@/components/agents/orb/ai-core";
+import type { AIState } from "@/components/agents/orb/agent-orb";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import {
type ChannelWork,
diff --git a/app/src/skins/openduet/menu-sheet.tsx b/app/src/skins/openduet/menu-sheet.tsx
index 8e106eb067..563b5700d9 100644
--- a/app/src/skins/openduet/menu-sheet.tsx
+++ b/app/src/skins/openduet/menu-sheet.tsx
@@ -29,7 +29,6 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
-import { agentListQueryOptions } from "@/lib/agents/queries";
import { signOutMutationOptions } from "@/lib/auth/mutations";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import { botAttentionQueryOptions } from "@/lib/bot-lifecycle/queries";
@@ -338,11 +337,6 @@ function ConversationRow({
}) {
const emphasis = useMessageListEmphasis();
const navigate = useNavigate();
- const bots = useQuery(agentListQueryOptions());
- // Each Bot's own avatar, drawn from its seed as OpenBot's Bot pages draw it.
- const seeds = channel.agentIds.map(
- (id) => bots.data?.find((bot) => bot.id === id)?.avatarSeed ?? id,
- );
const isOpen = useParams({
strict: false,
select: (params) =>
@@ -375,7 +369,11 @@ function ConversationRow({
to={group ? "/group/$channelId" : "/channel/$channelId"}
>
-
+
{channel.pinned ? (
diff --git a/app/src/skins/openmuse/frame.tsx b/app/src/skins/openmuse/frame.tsx
index 5108ce9f4b..c996720ee9 100644
--- a/app/src/skins/openmuse/frame.tsx
+++ b/app/src/skins/openmuse/frame.tsx
@@ -10,7 +10,12 @@ import {
} from "@tabler/icons-react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Link, useParams, useRouterState } from "@tanstack/react-router";
-import { type ReactNode, useEffect, useState } from "react";
+import {
+ type ReactNode,
+ useEffect,
+ useState,
+ useSyncExternalStore,
+} from "react";
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import AgentOrb from "@/components/agents/orb/agent-orb";
import { BotsNavBadge } from "@/components/bot-profile/attention";
@@ -53,6 +58,25 @@ const TABS: { id: MuseTab; label: string; icon: MuseIcon }[] = [
{ id: "apps", label: "Apps", icon: IconTriangleSquareCircle },
];
+const WIDE = "(min-width: 900px)";
+
+/**
+ * Whether the window is wide enough for the header's larger face, kept current as it is resized.
+ * The face is drawn on a canvas at a size in px, so the header picks the size here rather than in
+ * its classes.
+ */
+function useWide(): boolean {
+ return useSyncExternalStore(
+ (onChange) => {
+ const query = window.matchMedia(WIDE);
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+ },
+ () => window.matchMedia(WIDE).matches,
+ () => false,
+ );
+}
+
/**
* OpenMuse's shell around every OpenBot page: one centred column, the agent at the top, a bell for
* what waits on you, and a floating pill of five tabs. The page itself is OpenBot's, untouched —
@@ -89,6 +113,8 @@ export function OpenMuseFrame({ children }: { children: ReactNode }) {
const userId = user.data?.id;
const chosenId = useChosenBotId(userId);
const [sheet, setSheet] = useState(null);
+ // The centre face, in px: 44 on a phone, 52 from 900px wide, where the header grows.
+ const face = useWide() ? 52 : 44;
/*
* The Bot this screen is about, when it is about one: the Bot of a one-to-one conversation, the Bot
@@ -164,15 +190,15 @@ export function OpenMuseFrame({ children }: { children: ReactNode }) {
* The Bot's own OpenBot avatar, the one its page and the Bots roster draw. With no Bot
* yet, the deployment's own mark stands in.
*/}
-
+
{bot ? (
) : (
-
+
)}
diff --git a/app/src/theme.css b/app/src/theme.css
index 8e30caa8b0..d7e320b202 100644
--- a/app/src/theme.css
+++ b/app/src/theme.css
@@ -1,76 +1,151 @@
@custom-variant dark (&:is(.dark *));
+/*
+ * OpenBot's visual system, in the variables every screen already reads. Neutral greys with a cool
+ * bias; a near-black primary that turns light in the dark; one accent, `--brand`, kept for focus,
+ * selection and links and never for headings (shadcn's `--accent` stays the neutral hover fill); it
+ * reaches the focus ring today. Four status colours for how work stands, and the glass and shadow
+ * below, are here for the screens that adopt them. Every colour here has a dark value in `.dark` below, and
+ * `tests/visual-system.test.ts` fails if one is missing or a colour that carries words falls under
+ * 4.5:1 on a ground it sits on.
+ */
:root {
color-scheme: light;
- --background: oklch(0.985 0 0);
- --foreground: oklch(0.145 0 0);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.205 0 0);
- --primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.96 0 0);
- --muted-foreground: oklch(0.5 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
- --destructive: oklch(0.577 0.245 27.325);
- --success: oklch(0.6 0.118 184.704);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
+ --background: #f6f6f8;
+ --foreground: #18181b;
+ --card: #ffffff;
+ --card-foreground: #18181b;
+ --popover: #ffffff;
+ --popover-foreground: #18181b;
+ --primary: #111114;
+ --primary-foreground: #ffffff;
+ --secondary: #f1f1f4;
+ --secondary-foreground: #18181b;
+ --muted: #f1f1f4;
+ --muted-foreground: #6b6b75;
+ --accent: #f1f1f4;
+ --accent-foreground: #18181b;
+ --destructive: #b91c1c;
+ --success: #147a3a;
+ --border: rgb(24 24 27 / 9%);
+ --input: rgb(24 24 27 / 12%);
+ --ring: #6430ab;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
- --radius: 0.5rem;
- --sidebar: oklch(0.97 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
- --sidebar-primary-foreground: oklch(0.5 0 0);
- --sidebar-accent: oklch(0.95 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --radius: 0.55rem;
+ --sidebar: #f1f1f4;
+ --sidebar-foreground: #18181b;
+ --sidebar-primary: #6430ab;
+ --sidebar-primary-foreground: #ffffff;
+ /* A row lifts to the card's white on the sidebar's grey, the way a selected segment does. */
+ --sidebar-accent: #ffffff;
+ --sidebar-accent-foreground: #18181b;
+ --sidebar-border: rgb(24 24 27 / 9%);
+ --sidebar-ring: #6430ab;
+
+ /* The one accent, and what sits on it. */
+ --brand: #6430ab;
+ --brand-foreground: #ffffff;
+ /* How work stands: ready for review, needs you, working, couldn't finish. Only on status. */
+ --status-done: #147a3a;
+ --status-done-soft: #e6f4ea;
+ --status-needs: #a34a06;
+ --status-needs-soft: #fdf3e1;
+ --status-working: #1d4ed8;
+ --status-working-soft: #eaf0fd;
+ --status-problem: #b91c1c;
+ --status-problem-soft: #fbeaea;
+ /*
+ * Not registered with Tailwind: `--shadow-*` is its namespace, and a theme key of the same name
+ * would point at itself. Use them as `shadow-(--shadow-float)`.
+ */
+ --shadow-float:
+ 0 0 0 0.5px rgb(1 5 7 / 10%), 0 1px 1.5px rgb(1 5 7 / 6%),
+ 0 4px 12px -4px rgb(1 5 7 / 12%);
+ --shadow-card: 0 0 0 1px var(--border), 0 6px 18px -12px rgb(1 5 7 / 18%);
+ --shadow-raised: 0 0 0 1px var(--border), 0 10px 26px -14px rgb(1 5 7 / 20%);
+ /*
+ * Glass: the chrome that floats over a screen lets what scrolls under it show through, frosted.
+ * Translucent enough to read as a layer, opaque enough that text under it never competes with the
+ * controls on it. `--veil` is the same material laid over a whole screen, for a sheet that the
+ * screen stays visible behind. The `glass` and `veil` utilities below draw them.
+ */
+ --glass: rgb(255 255 255 / 80%);
+ --veil: rgb(246 246 248 / 74%);
+ --glass-edge: inset 0 0.5px 0 rgb(255 255 255 / 80%);
+ --shadow-glass: var(--glass-edge), var(--shadow-float);
+ /* The smallest thing a finger can hit. */
+ --target: 44px;
+ /*
+ * Motion. `--ease-out` and `--ease-in` are Tailwind's own names, so `ease-out` and `ease-in` draw
+ * these curves; `--ease-out` is the one `lib/motion.ts` already uses.
+ */
+ --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
+ --ease-in: cubic-bezier(0.7, 0, 0.84, 0);
+ --dur-tap: 120ms;
+ --dur-swap: 200ms;
+ --dur-enter: 250ms;
+ --dur-sheet: 350ms;
}
+/* Near-black and neutral, the same roles, and the primary action turns light. */
.dark {
color-scheme: dark;
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.922 0 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.269 0 0);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --success: oklch(0.704 0.14 182.503);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
+ --background: #111113;
+ --foreground: #f4f4f5;
+ --card: #1c1c1f;
+ --card-foreground: #f4f4f5;
+ --popover: #1c1c1f;
+ --popover-foreground: #f4f4f5;
+ --primary: #f4f4f5;
+ --primary-foreground: #111114;
+ --secondary: #26262b;
+ --secondary-foreground: #f4f4f5;
+ --muted: #26262b;
+ --muted-foreground: #8e8e98;
+ --accent: #26262b;
+ --accent-foreground: #f4f4f5;
+ --destructive: #f87171;
+ --success: #4ade80;
+ --border: rgb(255 255 255 / 10%);
+ --input: rgb(255 255 255 / 15%);
+ --ring: #b49ae8;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --sidebar: #18181b;
+ --sidebar-foreground: #f4f4f5;
+ --sidebar-primary: #b49ae8;
+ --sidebar-primary-foreground: #1a1033;
+ --sidebar-accent: #26262b;
+ --sidebar-accent-foreground: #f4f4f5;
+ --sidebar-border: rgb(255 255 255 / 10%);
+ --sidebar-ring: #b49ae8;
+
+ --brand: #b49ae8;
+ /* Dark text on the light lilac: white on it was 2.4:1. */
+ --brand-foreground: #1a1033;
+ --status-done: #4ade80;
+ --status-done-soft: #14301f;
+ --status-needs: #fbbf24;
+ --status-needs-soft: #3a2a0c;
+ --status-working: #7aa2ff;
+ --status-working-soft: #18233d;
+ --status-problem: #f87171;
+ --status-problem-soft: #3a1717;
+ --shadow-float:
+ 0 0 0 0.5px rgb(255 255 255 / 10%), 0 4px 12px -4px rgb(0 0 0 / 50%);
+ --shadow-card: 0 0 0 1px var(--border);
+ --shadow-raised: 0 0 0 1px var(--border), 0 10px 26px -14px rgb(0 0 0 / 60%);
+ --glass: rgb(28 28 31 / 82%);
+ --veil: rgb(17 17 19 / 72%);
+ --glass-edge: inset 0 0.5px 0 rgb(255 255 255 / 8%);
+ --shadow-glass: var(--glass-edge), var(--shadow-float);
}
* {
@@ -102,23 +177,11 @@ body {
*/
/*
- * Text is sized on a 15px base while everything else is on Tailwind's 16px rem.
- *
- * The root stays at the browser's 16px so spacing, sizes and radii land on Tailwind's 4px grid
- * (`p-4` is 16px, `h-8` is 32px). Only type keeps the scale it had under the old 15px root: each
- * size below is Tailwind's default multiplied by 15/16, so `text-sm` is still 13.125px. Line
- * heights are Tailwind's own unitless ratios and scale with them. `body` reads `--text-base`, so
- * unclassed text is 15px too.
+ * No `--text-*` here. Text follows the browser's own size, 16px by default, on Tailwind's own scale,
+ * like the spacing and the radii; OpenBot used to set every size to 15/16 of Tailwind's. The type
+ * scale below is in rem too, so it follows the person's text size.
*/
@theme inline {
- --text-xs: 0.703125rem; /* 11.25px */
- --text-sm: 0.8203125rem; /* 13.125px */
- --text-base: 0.9375rem; /* 15px */
- --text-lg: 1.0546875rem; /* 16.875px */
- --text-xl: 1.171875rem; /* 18.75px */
- --text-2xl: 1.40625rem; /* 22.5px */
- --text-3xl: 1.7578125rem; /* 28.125px */
- --text-4xl: 2.109375rem; /* 33.75px */
--font-heading: var(--font-sans);
--font-sans: "Inter Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
@@ -160,6 +223,81 @@ body {
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
+ --color-brand: var(--brand);
+ --color-brand-foreground: var(--brand-foreground);
+ --color-status-done: var(--status-done);
+ --color-status-done-soft: var(--status-done-soft);
+ --color-status-needs: var(--status-needs);
+ --color-status-needs-soft: var(--status-needs-soft);
+ --color-status-working: var(--status-working);
+ --color-status-working-soft: var(--status-working-soft);
+ --color-status-problem: var(--status-problem);
+ --color-status-problem-soft: var(--status-problem-soft);
+}
+
+/*
+ * The type scale: five sizes, in rem so they follow the person's text size. Inter's tracking tightens
+ * as it grows. Weights are not part of a size: 600 for titles, 500 for row titles, chips and
+ * buttons, 400 otherwise. `cn` knows these as font sizes (`lib/utils.ts`), so one given later
+ * replaces a primitive's `text-*` and the `leading-*` that came with it.
+ *
+ * type-display 24/28 screen titles
+ * type-nav 18/22 navigation rows, Settings rows
+ * type-body 17/23 messages, task and card titles
+ * type-sub 15/20 status lines, chips, the composer, section headers, buttons
+ * type-caption 13/16 captions, labels, the footer, timestamps, tags
+ */
+@utility type-display {
+ font-size: 1.5rem;
+ line-height: 1.75rem;
+ letter-spacing: -0.018em;
+}
+@utility type-nav {
+ font-size: 1.125rem;
+ line-height: 1.375rem;
+ letter-spacing: -0.011em;
+}
+@utility type-body {
+ font-size: 1.0625rem;
+ line-height: 1.4375rem;
+ letter-spacing: -0.011em;
+}
+@utility type-sub {
+ font-size: 0.9375rem;
+ line-height: 1.25rem;
+ letter-spacing: -0.006em;
+}
+@utility type-caption {
+ font-size: 0.8125rem;
+ line-height: 1rem;
+ letter-spacing: 0;
+}
+/*
+ * Frosted chrome over a scrolling screen. The blur is what makes the translucency readable: without
+ * it, a message passing under the composer would sit legibly behind its placeholder. A browser that
+ * cannot blur gets the opaque card instead, never a see-through bar.
+ */
+@utility glass {
+ background-color: var(--card);
+ @supports (backdrop-filter: blur(1px)) or
+ (-webkit-backdrop-filter: blur(1px)) {
+ background-color: var(--glass);
+ -webkit-backdrop-filter: blur(22px) saturate(180%);
+ backdrop-filter: blur(22px) saturate(180%);
+ }
+}
+@utility veil {
+ background-color: var(--background);
+ @supports (backdrop-filter: blur(1px)) or
+ (-webkit-backdrop-filter: blur(1px)) {
+ background-color: var(--veil);
+ -webkit-backdrop-filter: blur(28px) saturate(160%);
+ backdrop-filter: blur(28px) saturate(160%);
+ }
+}
+/* Inter's single-storey a (cv11) and flat-topped 3 (ss03). On `body`, so every screen has them. */
+@utility face {
+ font-feature-settings: "cv11", "ss03";
}
@layer base {
@@ -167,7 +305,7 @@ body {
@apply border-border outline-ring/50;
}
body {
- @apply bg-background text-foreground text-base;
+ @apply bg-background text-foreground face;
}
html {
@apply font-sans;
diff --git a/app/tests/bot-browser.test.tsx b/app/tests/bot-browser.test.tsx
index dd5ab6d928..db38a90ffa 100644
--- a/app/tests/bot-browser.test.tsx
+++ b/app/tests/bot-browser.test.tsx
@@ -160,6 +160,9 @@ async function server(path: string, init?: RequestInit): Promise {
return Response.json({
user: { id: ME, email: "me@example.com", role: "user", onboarding: null },
});
+ // The screen's header face looks its Bot up here. Answered, so a refused lookup is not left
+ // retrying in the app's query client after this file, where the next file's Bots list would wait on it.
+ if (path === "/api/agents") return Response.json({ agents: [] });
if (path === "/api/settings/preferences") {
// As the server's own route takes it: one preference at a time, merged into the rest.
if (method === "PATCH" && body && "narration" in body)
diff --git a/app/tests/bot-look.test.tsx b/app/tests/bot-look.test.tsx
new file mode 100644
index 0000000000..181ce3fcac
--- /dev/null
+++ b/app/tests/bot-look.test.tsx
@@ -0,0 +1,428 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ expect,
+ test,
+} from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClientProvider, useQuery } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { cleanup, render, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { AbstractAvatar } from "@/components/agents/abstract-avatar";
+import { SetupSections } from "@/components/bot-profile/setup";
+import { ChannelAvatar } from "@/components/channels/avatar";
+import { agentListQueryOptions, agentQueryOptions } from "@/lib/agents/queries";
+import { queryClient } from "@/query-client";
+import {
+ AgentNotManageableError,
+ type AgentProfileStore,
+} from "../../server/src/agents/profile-store";
+import type { AgentProfile } from "../../server/src/agents/profile-types";
+import { createAgentRoutes } from "../../server/src/agents/routes";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * Choosing how a Bot looks, from the Look section of its Setup page.
+ *
+ * The routes are the server's own, mounted behind `fetch` as `bot-setup.test.tsx` mounts them, so a
+ * refused look is refused by the real check. The page is drawn with the app's own query client, so
+ * a save's invalidation reaches every other avatar of the Bot on the page, as it does in the app:
+ * the conversation list's, which knows only the Bot's id, and the Bots list's.
+ */
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(() => {
+ cleanup();
+ queryClient.clear();
+});
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const originalFetch = globalThis.fetch;
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+const actor = {
+ id: "owner",
+ email: "owner@example.test",
+ role: "user" as const,
+};
+
+/** Every update the store was asked to make, as the route parsed it. */
+let updates: Record[] = [];
+/**
+ * While true, each save waits at the door until the test lets it through, one at a time, so what
+ * shows before the server answers can be seen, and two saves can settle in either order.
+ */
+let holding = false;
+const waiting: (() => void)[] = [];
+
+/** Lets the oldest held save reach the server, once it has been sent. */
+async function letOneThrough() {
+ await waitFor(() => expect(waiting.length).toBeGreaterThan(0));
+ waiting.shift()?.();
+}
+
+beforeEach(() => {
+ updates = [];
+ holding = false;
+ waiting.length = 0;
+});
+
+/** "expenses" hashes to ocean, which the Look section calls Blue. */
+function serve({
+ ownerUserId = actor.id,
+ refuse = false,
+ avatarSeed = "expenses",
+ list = Promise.resolve(),
+}: {
+ ownerUserId?: string;
+ refuse?: boolean;
+ avatarSeed?: string;
+ /** What the Bots list waits for before it answers. */
+ list?: Promise;
+} = {}) {
+ let profile: AgentProfile = {
+ id: "expenses",
+ name: "Expenses",
+ title: "Finance Operations",
+ roleDescription: "Review receipts.",
+ avatarSeed,
+ ownerUserId,
+ visibility: "private",
+ systemOwned: false,
+ hidden: false,
+ pinned: false,
+ deletedAt: null,
+ endpoint: "http://localhost:4201/ag-ui",
+ hasAuth: false,
+ hasCallbackToken: false,
+ isolated: false,
+ };
+ const store = {
+ get: async () => profile,
+ list: async () => [profile],
+ update: async (_actor: unknown, id: string, value: object) => {
+ updates.push({ ...value });
+ if (refuse) throw new AgentNotManageableError(id);
+ const { endpoint: _endpoint, ...rest } = value as { endpoint?: string };
+ profile = { ...profile, ...rest };
+ return profile;
+ },
+ } as unknown as AgentProfileStore;
+ const auth: Parameters[1] = async (
+ context,
+ next,
+ ) => {
+ context.set("actor", actor);
+ await next();
+ };
+ const routes = createAgentRoutes(
+ store,
+ auth,
+ false,
+ undefined,
+ new Set(),
+ undefined,
+ true,
+ "http://localhost:4201/ag-ui",
+ undefined,
+ undefined,
+ { available: true },
+ );
+ globalThis.fetch = Object.assign(
+ async (
+ path: Parameters[0],
+ init?: Parameters[1],
+ ) => {
+ if (path === "/api/admin/enterprise/me") {
+ return Response.json({
+ capabilities: { isolatedComputers: true },
+ enforced: true,
+ });
+ }
+ if (typeof path !== "string" || !path.startsWith("/api/agents")) {
+ throw new Error(`unexpected ${String(path)}`);
+ }
+ if (init?.method === "PATCH" && holding) {
+ await new Promise((resolve) => waiting.push(resolve));
+ }
+ if (path === "/api/agents") await list;
+ return routes.request(
+ new Request(
+ `http://openbot.test${path.slice("/api/agents".length) || "/"}`,
+ init,
+ ),
+ );
+ },
+ { preconnect: originalFetch.preconnect },
+ );
+}
+
+/** The Setup page's sections, and two other places the same Bot's face is drawn. */
+function Page() {
+ const agent = useQuery(agentQueryOptions("expenses"));
+ const roster = useQuery(agentListQueryOptions());
+ return (
+ <>
+ {agent.data ? : null}
+
+
+ {roster.data?.map((bot) => (
+
+ ))}
+
+ >
+ );
+}
+
+function draw() {
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ routeTree: createRootRoute({ component: Page }),
+ });
+ const view = render(
+
+
+ ,
+ );
+ const user = userEvent.setup({ document: view.baseElement.ownerDocument });
+ return { view, user };
+}
+
+/** The Look section, once the Bot has loaded. */
+async function lookOf(view: ReturnType) {
+ const heading = await view.findByRole("heading", { name: "Look" });
+ const section = heading.closest("section");
+ if (!section) throw new Error("The Look heading is not in a section.");
+ return within(section);
+}
+
+/** The seeds the face is drawn from, in the Look section and everywhere else on the page. */
+function seeds(view: ReturnType) {
+ const of = (root: Element) =>
+ [...root.querySelectorAll("[data-seed]")].map((orb) =>
+ orb.getAttribute("data-seed"),
+ );
+ const section = view
+ .getByRole("heading", { name: "Look" })
+ .closest("section");
+ return {
+ preview: section ? of(section) : [],
+ elsewhere: of(view.getByTestId("elsewhere")),
+ };
+}
+
+const COLOURS = [
+ "Orange",
+ "Green",
+ "Yellow",
+ "Pink",
+ "Blue",
+ "Light blue",
+ "Coral",
+ "Grey",
+];
+
+test("the Look section shows the Bot's face large, and its colour named and chosen", async () => {
+ serve();
+ const { view } = draw();
+ const look = await lookOf(view);
+
+ expect(
+ look.getByText(
+ "Pick a colour, or draw a new face. It shows wherever this Bot appears.",
+ ),
+ ).toBeTruthy();
+ const group = look.getByRole("radiogroup", { name: "Colour" });
+ const radios = within(group).getAllByRole("radio") as HTMLInputElement[];
+ expect(radios.map((radio) => radio.getAttribute("aria-label"))).toEqual(
+ COLOURS,
+ );
+ expect(radios.filter((radio) => radio.checked).map((r) => r.value)).toEqual([
+ "ocean",
+ ]);
+ expect(look.getByText("Blue")).toBeTruthy();
+ expect(look.getByRole("button", { name: "New face" })).toBeTruthy();
+
+ const preview = look.getByRole("img", { name: "Expenses" });
+ expect(preview.style.width).toBe("72px");
+ expect(seeds(view).preview).toEqual(["expenses"]);
+});
+
+test("a colour saves at once, shows at once, and every avatar of the Bot follows", async () => {
+ serve();
+ holding = true;
+ const { view, user } = draw();
+ const look = await lookOf(view);
+ await waitFor(() =>
+ expect(seeds(view).elsewhere).toEqual(["expenses", "expenses"]),
+ );
+
+ await user.click(look.getByRole("radio", { name: "Green" }));
+
+ // The face shows it before the server has answered, and nothing else has changed yet.
+ expect(seeds(view)).toEqual({
+ preview: ["moss:expenses"],
+ elsewhere: ["expenses", "expenses"],
+ });
+ expect(
+ (look.getByRole("radio", { name: "Green" }) as HTMLInputElement).checked,
+ ).toBe(true);
+ expect(look.getByText("Green")).toBeTruthy();
+
+ await letOneThrough();
+ await waitFor(() =>
+ expect(seeds(view).elsewhere).toEqual(["moss:expenses", "moss:expenses"]),
+ );
+ expect(seeds(view).preview).toEqual(["moss:expenses"]);
+ // The rest of the profile rode along as stored, and only the look changed.
+ expect(updates).toEqual([
+ {
+ name: "Expenses",
+ title: "Finance Operations",
+ roleDescription: "Review receipts.",
+ visibility: "private",
+ avatarSeed: "moss:expenses",
+ },
+ ]);
+ expect(view.queryByRole("alert")).toBeNull();
+});
+
+test("New face keeps the colour and draws a face nobody had", async () => {
+ serve();
+ const { view, user } = draw();
+ const look = await lookOf(view);
+
+ await user.click(look.getByRole("button", { name: "New face" }));
+
+ await waitFor(() => expect(updates).toHaveLength(1));
+ const face = String(updates[0]?.avatarSeed);
+ expect(face).toMatch(/^ocean:[a-z0-9]{8}$/);
+ await waitFor(() => expect(seeds(view).elsewhere).toEqual([face, face]));
+ expect(seeds(view).preview).toEqual([face]);
+ expect(look.getByText("Blue")).toBeTruthy();
+
+ await user.click(look.getByRole("button", { name: "New face" }));
+ await waitFor(() => expect(updates).toHaveLength(2));
+ expect(updates[1]?.avatarSeed).not.toBe(face);
+ expect(String(updates[1]?.avatarSeed)).toMatch(/^ocean:/);
+});
+
+test("the arrow keys move through the colours, as in any radio group, and the newest choice wins", async () => {
+ serve();
+ holding = true;
+ const { view, user } = draw();
+ const look = await lookOf(view);
+ const radio = (name: string) =>
+ look.getByRole("radio", { name }) as HTMLInputElement;
+
+ radio("Blue").focus();
+ await user.keyboard("{ArrowRight}");
+ expect(document.activeElement).toBe(radio("Light blue"));
+ expect(radio("Light blue").checked).toBe(true);
+
+ // Still live while that one saves, so the next arrow carries on from where focus is.
+ await user.keyboard("{ArrowRight}");
+ expect(document.activeElement).toBe(radio("Coral"));
+ expect(radio("Coral").checked).toBe(true);
+ expect(seeds(view).preview).toEqual(["coral:expenses"]);
+
+ // The first saves and the Bot is refetched with it; the face keeps the newer choice.
+ await letOneThrough();
+ await waitFor(() =>
+ expect(seeds(view).elsewhere).toEqual(["sky:expenses", "sky:expenses"]),
+ );
+ expect(seeds(view).preview).toEqual(["coral:expenses"]);
+ expect(radio("Coral").checked).toBe(true);
+
+ await letOneThrough();
+ await waitFor(() =>
+ expect(seeds(view).elsewhere).toEqual(["coral:expenses", "coral:expenses"]),
+ );
+ expect(updates.map((update) => update.avatarSeed)).toEqual([
+ "sky:expenses",
+ "coral:expenses",
+ ]);
+ expect(seeds(view).preview).toEqual(["coral:expenses"]);
+ expect(radio("Coral").checked).toBe(true);
+});
+
+test("a look that can't be saved goes back, and says so in one sentence", async () => {
+ serve({ refuse: true });
+ const { view, user } = draw();
+ const look = await lookOf(view);
+
+ await user.click(look.getByRole("radio", { name: "Pink" }));
+
+ expect((await look.findByRole("alert")).textContent).toBe(
+ "Couldn't save that look, so the last one is back.",
+ );
+ expect(seeds(view)).toEqual({
+ preview: ["expenses"],
+ elsewhere: ["expenses", "expenses"],
+ });
+ expect(
+ (look.getByRole("radio", { name: "Blue" }) as HTMLInputElement).checked,
+ ).toBe(true);
+ expect(look.getByText("Blue")).toBeTruthy();
+
+ // The next choice takes the sentence away while it saves.
+ holding = true;
+ await user.click(look.getByRole("radio", { name: "Green" }));
+ expect(look.queryByRole("alert")).toBeNull();
+ await letOneThrough();
+ expect(await look.findByRole("alert")).toBeTruthy();
+ expect(updates).toHaveLength(2);
+});
+
+test("someone who can't edit the Bot doesn't see the Look section", async () => {
+ serve({ ownerUserId: "someone-else" });
+ const { view } = draw();
+
+ expect(await view.findByText("Finance Operations")).toBeTruthy();
+ expect(view.queryByRole("heading", { name: "Look" })).toBeNull();
+ expect(view.queryByRole("radiogroup")).toBeNull();
+ expect(view.queryByRole("button", { name: "New face" })).toBeNull();
+});
+
+test("a conversation's face, which knows only the Bot's id, waits for the Bots list and draws the chosen look", async () => {
+ let answer = () => {};
+ serve({
+ avatarSeed: "coral:k2p9x7aq",
+ list: new Promise((resolve) => {
+ answer = resolve;
+ }),
+ });
+ const view = render(
+
+
+
+ ,
+ );
+ const drawn = () =>
+ [...view.container.querySelectorAll("[data-seed]")].map((orb) =>
+ orb.getAttribute("data-seed"),
+ );
+
+ // No face while the list loads, rather than the id's face and then another.
+ await settleReactWork();
+ expect(drawn()).toEqual([]);
+
+ answer();
+ // A Bot the list doesn't hold keeps the face of its id.
+ await waitFor(() =>
+ expect(drawn()).toEqual(["coral:k2p9x7aq", "coral:k2p9x7aq", "not-listed"]),
+ );
+});
diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx
new file mode 100644
index 0000000000..610c4ffb70
--- /dev/null
+++ b/app/tests/bot-orb.test.tsx
@@ -0,0 +1,895 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ describe,
+ expect,
+ test,
+} from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { BOT_COLOURS, seedFor } from "@openbot/shared/bot-look";
+import { cleanup, render } from "@testing-library/react";
+import { AbstractAvatar } from "@/components/agents/abstract-avatar";
+import AgentOrb, { type AIState } from "@/components/agents/orb/agent-orb";
+import {
+ BotOrb,
+ type BotOrbState,
+ followPointer,
+ restingGaze,
+} from "@/components/agents/orb/bot-orb";
+import {
+ drawBotOrb,
+ FINISH_HELD,
+ FINISH_S,
+ finishPhase,
+ OPENBOT_LOOK,
+ ORB_PALETTES,
+ type OrbLook,
+ type OrbPose,
+ oklch,
+ orbLookFor,
+ palette,
+ RAYS,
+ samePicture,
+ track,
+} from "@/components/agents/orb/bot-orb-draw";
+import { subscribePointer } from "@/components/agents/orb/orb-clock";
+import { settleReactWork } from "./settle-react-work";
+
+beforeAll(() => GlobalRegistrator.register({ url: "http://localhost/" }));
+afterEach(() => cleanup());
+afterAll(async () => {
+ await settleReactWork();
+ await GlobalRegistrator.unregister();
+});
+
+const REST: OrbPose = {
+ ta: 1.6,
+ listen: 0,
+ think: 0,
+ done: 0,
+ smile: 0,
+ tilt: 0,
+ ax: 0,
+ ay: 0.04,
+ blink: 0,
+ nod: 0,
+ doneAge: -1,
+ noticeAge: -1,
+ finishAge: -1,
+ breath: 0,
+};
+
+/**
+ * Just enough of a 2D context to run the drawing: it counts every stroke, counts the frames drawn,
+ * each of which starts by clearing the canvas, and keeps where each soft blob and each eye is drawn
+ * and in what colour.
+ */
+function fakeContext() {
+ const marks: string[] = [];
+ const gradient = {
+ addColorStop: (_: number, colour: string) => marks.push(colour),
+ };
+ const ctx = {
+ strokes: 0,
+ draws: 0,
+ marks,
+ stroke() {
+ this.strokes++;
+ },
+ clearRect() {
+ this.draws++;
+ },
+ translate(x: number, y: number) {
+ marks.push(`at ${x.toFixed(2)},${y.toFixed(2)}`);
+ },
+ createLinearGradient: () => gradient,
+ createRadialGradient(x: number, y: number) {
+ marks.push(`blob ${x.toFixed(2)},${y.toFixed(2)}`);
+ return gradient;
+ },
+ };
+ return new Proxy(ctx, {
+ get: (target, key) =>
+ key in target ? target[key as keyof typeof target] : () => {},
+ set: (target, key, value) => {
+ (target as Record)[key] = value;
+ return true;
+ },
+ });
+}
+
+/** One frame of an orb 60px across in `pose`, drawn on a fake context. */
+const drawn = (pose: OrbPose, look: OrbLook = OPENBOT_LOOK) => {
+ const ctx = fakeContext();
+ drawBotOrb(ctx as unknown as CanvasRenderingContext2D, 60, pose, {
+ dark: false,
+ look,
+ });
+ return ctx;
+};
+
+/** Every stroke one frame draws: the rim, the landing's ping, and a finish's rays and check. */
+const strokesOf = (pose: OrbPose) => drawn(pose).strokes;
+
+/** The Bots a fresh OpenBot ships with, whose ids are their seeds. */
+const TEAM = [
+ "expense-review",
+ "feedback-digest",
+ "interview-notes",
+ "meeting-follow-ups",
+ "onboarding-buddy",
+ "oncall-handover",
+ "release-notes",
+ "research-desk",
+ "ticket-triage",
+];
+
+describe("the orb's motion", () => {
+ test("an eased key arrives and a spring overshoots, then both settle on the target", () => {
+ const eased = { from: 0, keys: [{ at: 1, to: 1, ease: 0.4 }] };
+ expect(track(eased, 0.5)).toBe(0);
+ expect(track(eased, 1.2)).toBeGreaterThan(0);
+ expect(track(eased, 2)).toBe(1);
+
+ const sprung = {
+ from: 0,
+ keys: [{ at: 0, to: 1, spring: [14, 0.5] as const }],
+ };
+ const peak = Math.max(
+ ...Array.from({ length: 60 }, (_, i) => track(sprung, i / 60)),
+ );
+ expect(peak).toBeGreaterThan(1);
+ expect(track(sprung, 5)).toBeCloseTo(1, 4);
+ });
+
+ test("a later key moves on from where the earlier one left it", () => {
+ const tr = {
+ from: 0,
+ keys: [
+ { at: 0, to: 1, ease: 0.4 },
+ { at: 1, to: 0.25, ease: 0.4 },
+ ],
+ };
+ expect(track(tr, 0.9)).toBe(1);
+ expect(track(tr, 3)).toBe(0.25);
+ });
+});
+
+describe("the colours", () => {
+ test("every palette's base is pale and its shade deep", () => {
+ for (const name of ORB_PALETTES) {
+ const pal = palette(name, 1, 0);
+ expect(Math.min(...pal.base)).toBeGreaterThan(200);
+ expect(Math.max(...pal.deep)).toBeLessThan(230);
+ expect(Math.max(...pal.shade)).toBeLessThan(100);
+ }
+ });
+
+ test("a colour outside sRGB loses chroma rather than turning into another hue", () => {
+ const [r, g, b] = oklch(0.6, 0.5, 262);
+ expect(b).toBeGreaterThan(r);
+ expect(b).toBeGreaterThan(g);
+ });
+
+ test("a seed always gets the same look", () => {
+ for (const seed of TEAM) {
+ expect(orbLookFor(seed)).toEqual(orbLookFor(seed));
+ expect(ORB_PALETTES).toContain(orbLookFor(seed).palette);
+ }
+ // Pinned, so a change to the hash, which would change every Bot's face, can't land unnoticed.
+ expect(orbLookFor("research-desk").palette).toBe("ocean");
+ expect(orbLookFor("ticket-triage").palette).toBe("sun");
+ expect(ORB_PALETTES).toContain(orbLookFor("").palette);
+ });
+
+ test("nine palettes, and a team of nine seeds uses more than four of them", () => {
+ expect(ORB_PALETTES).toHaveLength(9);
+ expect(new Set(ORB_PALETTES).size).toBe(9);
+ const used = TEAM.map((seed) => orbLookFor(seed).palette);
+ expect(new Set(used).size).toBeGreaterThan(4);
+ });
+
+ test("no Bot is violet, which is OpenBot's own, and every other colour is given", () => {
+ const seeds = [
+ ...TEAM,
+ "",
+ ...Array.from({ length: 400 }, (_, i) => `bot-${i}`),
+ ];
+ const given = new Set(seeds.map((seed) => orbLookFor(seed).palette));
+ expect(given.has("violet")).toBe(false);
+ expect([...given].sort()).toEqual(
+ ORB_PALETTES.filter((name) => name !== "violet").sort(),
+ );
+ expect(OPENBOT_LOOK.palette).toBe("violet");
+ });
+
+ test("every Bot in the default team is drawn unlike every other, even two in one colour", () => {
+ const apart = (a: number, b: number) => {
+ const d = Math.abs(a - b) % (2 * Math.PI);
+ return Math.min(d, 2 * Math.PI - d);
+ };
+ let sharing = 0;
+ for (const [i, a] of TEAM.entries()) {
+ for (const b of TEAM.slice(i + 1)) {
+ const one = orbLookFor(a);
+ const other = orbLookFor(b);
+ expect(drawn(REST, one).marks).not.toEqual(drawn(REST, other).marks);
+ if (one.palette !== other.palette) continue;
+ sharing++;
+ // Their colours sit at least a sixth of a turn apart, which shows at any size.
+ expect(apart(one.turn, other.turn)).toBeGreaterThan(Math.PI / 3);
+ }
+ }
+ // Eight colours can't go round nine Bots, so the team has Bots that share one.
+ expect(sharing).toBeGreaterThan(0);
+ });
+
+ test("every Bot that never chose a look keeps the face it had", () => {
+ // Every part of every default Bot's look, pinned: a seed that names no colour draws as before.
+ expect(
+ Object.fromEntries(TEAM.map((seed) => [seed, orbLookFor(seed)])),
+ ).toEqual({
+ "expense-review": {
+ palette: "berry",
+ turn: 0.8835729338221293,
+ hue: 1,
+ eyes: 1.07375,
+ },
+ "feedback-digest": {
+ palette: "sky",
+ turn: 2.6261751088602177,
+ hue: -3,
+ eyes: 1.03125,
+ },
+ "interview-notes": {
+ palette: "slate",
+ turn: 3.436116964863836,
+ hue: 3,
+ eyes: 0.948125,
+ },
+ "meeting-follow-ups": {
+ palette: "coral",
+ turn: 2.5034566458293663,
+ hue: -2,
+ eyes: 0.975625,
+ },
+ "onboarding-buddy": {
+ palette: "moss",
+ turn: 5.6941366846315,
+ hue: 1,
+ eyes: 1.065,
+ },
+ "oncall-handover": {
+ palette: "coral",
+ turn: 0.6872233929727672,
+ // A nudge that rounds to nothing from below, which draws the same as 0.
+ hue: -0,
+ eyes: 0.945,
+ },
+ "release-notes": {
+ palette: "ocean",
+ turn: 6.062292073724054,
+ hue: 1,
+ eyes: 1.015,
+ },
+ "research-desk": {
+ palette: "ocean",
+ turn: 1.4480778637640452,
+ hue: 4,
+ eyes: 0.9875,
+ },
+ "ticket-triage": {
+ palette: "sun",
+ turn: 1.6935147898257479,
+ hue: 2,
+ eyes: 0.92625,
+ },
+ });
+ });
+
+ test("a seed that names a colour is drawn in it, and the rest of its look still comes from the whole seed", () => {
+ for (const colour of BOT_COLOURS) {
+ for (let i = 0; i < 20; i++) {
+ expect(orbLookFor(seedFor(colour, `face-${i}`)).palette).toBe(colour);
+ }
+ }
+ // The pattern, hue and eyes are the hash's, as for any seed: "ember:k2p9x7aq" hashes to ocean,
+ // and is drawn in ember with the turn, nudge and eyes that hash gives it.
+ expect(orbLookFor("ember:k2p9x7aq")).toEqual({
+ palette: "ember",
+ turn: 4.246058820867455,
+ hue: -1,
+ eyes: 0.945,
+ });
+ // So two faces in one chosen colour still look apart, and so does one face in two colours.
+ const one = orbLookFor(seedFor("sky", "a"));
+ expect(orbLookFor(seedFor("sky", "b")).turn).not.toBe(one.turn);
+ expect(orbLookFor(seedFor("moss", "a")).turn).not.toBe(one.turn);
+ });
+
+ test("violet, or a colour no Bot has, is not honoured: the seed is drawn as any other", () => {
+ expect(orbLookFor("violet:k2p9x7aq").palette).toBe("berry");
+ expect(orbLookFor("purple:k2p9x7aq").palette).toBe("moss");
+ expect(orbLookFor("Moss:k2p9x7aq").palette).toBe("ocean");
+ const named = Array.from(
+ { length: 200 },
+ (_, i) => orbLookFor(`violet:${i}`).palette,
+ );
+ expect(named).not.toContain("violet");
+ });
+
+ test("the colours a seed can name are exactly the ones the orb draws a Bot in", () => {
+ expect([...BOT_COLOURS].sort()).toEqual(
+ ORB_PALETTES.filter((name) => name !== "violet").sort(),
+ );
+ });
+
+ test("each palette is a colour of its own", () => {
+ const bases = ORB_PALETTES.map((name) => palette(name, 1, 0).main.join());
+ expect(new Set(bases).size).toBe(ORB_PALETTES.length);
+ });
+});
+
+describe("the drawing", () => {
+ test("has no grilles: at rest its only stroke is the sphere's rim", () => {
+ expect(strokesOf(REST)).toBe(1);
+ });
+
+ test("two poses draw the same picture once what their ages time is over, not while it plays", () => {
+ expect(samePicture(REST, { ...REST })).toBe(true);
+ expect(samePicture(REST, { ...REST, ax: REST.ax + 0.01 })).toBe(false);
+ // A ping long rung, a perk long faded and a finish long over: the orb at rest.
+ expect(
+ samePicture(REST, { ...REST, doneAge: 4, noticeAge: 9, finishAge: 30 }),
+ ).toBe(true);
+ expect(
+ samePicture({ ...REST, doneAge: 0.2 }, { ...REST, doneAge: 0.3 }),
+ ).toBe(false);
+ });
+});
+
+describe("a finish", () => {
+ test("the eyes give way to a check while the rays fly, then the eyes come back", () => {
+ expect(finishPhase(-1)).toMatchObject({ check: 0, rays: -1, eyes: 1 });
+
+ const burst = finishPhase(0.4);
+ expect(burst.eyes).toBe(0);
+ expect(burst.check).toBe(1);
+ expect(burst.rays).toBeGreaterThan(0);
+ expect(burst.rays).toBeLessThan(1);
+
+ // Held: the check whole, the burst over.
+ expect(finishPhase(FINISH_HELD)).toMatchObject({
+ check: 1,
+ checkAlpha: 1,
+ rays: -1,
+ eyes: 0,
+ });
+
+ expect(finishPhase(FINISH_S)).toEqual(finishPhase(-1));
+ });
+
+ test("the check strokes on along its length", () => {
+ const early = finishPhase(0.12).check;
+ expect(early).toBeGreaterThan(0);
+ expect(early).toBeLessThan(finishPhase(0.24).check);
+ });
+
+ test("draws its rays and its check over the orb, and nothing once it is over", () => {
+ const rest = strokesOf(REST);
+ expect(strokesOf({ ...REST, finishAge: 0.4 })).toBe(rest + RAYS + 1);
+ expect(strokesOf({ ...REST, finishAge: FINISH_HELD })).toBe(rest + 1);
+ expect(strokesOf({ ...REST, finishAge: FINISH_S })).toBe(rest);
+ });
+});
+
+/** Every canvas draws on `ctx` until `restore`, as if each had its own 2D context. */
+function withContext(ctx: ReturnType) {
+ const getContext = HTMLCanvasElement.prototype.getContext;
+ HTMLCanvasElement.prototype.getContext = (() =>
+ ctx) as unknown as typeof getContext;
+ return () => {
+ HTMLCanvasElement.prototype.getContext = getContext;
+ };
+}
+
+/**
+ * Animation frames that run only when the test says, as the browser would at its next paint, at
+ * the time the test gives or now: `requested` counts every frame asked for, `pending` the ones not
+ * yet run or cancelled.
+ */
+function manualFrames() {
+ const queue = new Map();
+ let next = 1;
+ let requested = 0;
+ const request = window.requestAnimationFrame;
+ const cancel = window.cancelAnimationFrame;
+ window.requestAnimationFrame = (callback) => {
+ requested++;
+ queue.set(next, callback);
+ return next++;
+ };
+ window.cancelAnimationFrame = (id) => {
+ queue.delete(id);
+ };
+ return {
+ get requested() {
+ return requested;
+ },
+ get pending() {
+ return queue.size;
+ },
+ run(now = performance.now()) {
+ const due = [...queue.values()];
+ queue.clear();
+ for (const callback of due) callback(now);
+ },
+ restore() {
+ window.requestAnimationFrame = request;
+ window.cancelAnimationFrame = cancel;
+ },
+ };
+}
+
+/** An `IntersectionObserver` that reports only what the test tells it to. */
+class Sight {
+ static all: Sight[] = [];
+ readonly root = null;
+ readonly rootMargin = "";
+ readonly thresholds = [];
+ targets: Element[] = [];
+
+ constructor(private readonly callback: IntersectionObserverCallback) {
+ Sight.all.push(this);
+ }
+
+ observe(target: Element) {
+ this.targets.push(target);
+ }
+
+ unobserve(target: Element) {
+ this.targets = this.targets.filter((t) => t !== target);
+ }
+
+ disconnect() {
+ this.targets = [];
+ }
+
+ takeRecords() {
+ return [];
+ }
+
+ static show(target: Element, isIntersecting: boolean) {
+ for (const sight of Sight.all) {
+ if (!sight.targets.includes(target)) continue;
+ sight.callback(
+ [{ target, isIntersecting } as IntersectionObserverEntry],
+ sight as unknown as IntersectionObserver,
+ );
+ }
+ }
+}
+
+/** Runs `body` with `IntersectionObserver` set to `observer`, or missing when it is undefined. */
+function withObserver(observer: typeof Sight | undefined, body: () => void) {
+ const original = globalThis.IntersectionObserver;
+ if (observer) {
+ globalThis.IntersectionObserver =
+ observer as unknown as typeof IntersectionObserver;
+ } else {
+ Reflect.deleteProperty(globalThis, "IntersectionObserver");
+ }
+ try {
+ body();
+ } finally {
+ globalThis.IntersectionObserver = original;
+ Sight.all = [];
+ }
+}
+
+const orbOf = (container: HTMLElement) =>
+ container.querySelector("[data-state]") as HTMLElement;
+
+describe("", () => {
+ test("holds its footprint, hides from assistive tech, and draws on a canvas twice its size", () => {
+ const view = render();
+ const orb = view.container.firstElementChild as HTMLElement;
+ expect(orb.getAttribute("aria-hidden")).toBe("true");
+ expect(orb.dataset.state).toBe("listening");
+ expect(orb.dataset.palette).toBe("violet");
+ expect(orb.style.width).toBe("40px");
+ const canvas = orb.querySelector("canvas") as HTMLCanvasElement;
+ expect(canvas.style.width).toBe("80px");
+ expect(canvas.style.left).toBe("-20px");
+ });
+
+ test("is a block, so a small face never sits on a line's baseline and drops out of its circle", () => {
+ const view = render();
+ const orb = view.container.firstElementChild as HTMLElement;
+ expect(orb.classList.contains("block")).toBe(true);
+ expect(orb.classList.contains("inline-block")).toBe(false);
+ });
+
+ test("still, it draws once and again only when the theme changes, never on a frame loop", async () => {
+ const ctx = fakeContext();
+ const frames = manualFrames();
+ const restore = withContext(ctx);
+ try {
+ render();
+ expect(ctx.draws).toBe(1);
+ document.documentElement.classList.add("dark");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(ctx.draws).toBe(2);
+ expect(frames.requested).toBe(0);
+
+ cleanup();
+ render();
+ expect(frames.requested).toBe(1);
+ } finally {
+ cleanup();
+ document.documentElement.classList.remove("dark");
+ frames.restore();
+ restore();
+ }
+ });
+
+ test("still, finished is the check without the burst", () => {
+ const strokes = (state?: "finished") => {
+ const ctx = fakeContext();
+ const restore = withContext(ctx);
+ try {
+ render(
+ ,
+ );
+ cleanup();
+ return ctx.strokes;
+ } finally {
+ restore();
+ }
+ };
+ expect(strokes("finished")).toBe(strokes() + 1);
+ });
+
+ test("follows a change of state", () => {
+ const view = render();
+ expect(orbOf(view.container).dataset.state).toBe("idle");
+ view.rerender();
+ expect(orbOf(view.container).dataset.state).toBe("thinking");
+ view.rerender();
+ expect(orbOf(view.container).dataset.state).toBe("finished");
+ });
+
+ test("fifty orbs share one animation frame", () => {
+ withObserver(undefined, () => {
+ const ctx = fakeContext();
+ const frames = manualFrames();
+ const restore = withContext(ctx);
+ try {
+ render(
+