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( +
+ {Array.from({ length: 50 }, (_, i) => `bot-${i}`).map((id) => ( + + ))} +
, + ); + expect(frames.requested).toBe(1); + frames.run(); + expect(ctx.draws).toBe(50); + expect(frames.requested).toBe(2); + + // The last one gone, the clock stops asking for frames. + cleanup(); + expect(frames.pending).toBe(0); + } finally { + cleanup(); + frames.restore(); + restore(); + } + }); + }); + + const pointAt = (x: number, y: number) => + window.dispatchEvent( + new PointerEvent("pointermove", { clientX: x, clientY: y }), + ); + + /** + * One orb `size` px across, its corner at (100, 100) on screen. `play` runs its frames from `from` + * to `to` seconds after it mounts, `fps` a second, calling `each` before every frame, and says how + * many of them it drew. + */ + function onScreen(size: number) { + const ctx = fakeContext(); + const frames = manualFrames(); + const restore = withContext(ctx); + const view = render(); + orbOf(view.container).getBoundingClientRect = () => + ({ left: 100, top: 100, width: size, height: size }) as DOMRect; + const start = performance.now(); + return { + play(from: number, to: number, fps = 60, each?: (t: number) => void) { + const before = ctx.draws; + for (let i = Math.ceil(from * fps); i < to * fps; i++) { + each?.(i / fps); + frames.run(start + (i / fps) * 1000); + } + return ctx.draws - before; + }, + done() { + cleanup(); + frames.restore(); + restore(); + }, + }; + } + + test("a small orb at rest with the pointer still draws nothing once it settles, and draws again when the pointer moves", () => { + withObserver(undefined, () => { + const orb = onScreen(28); + try { + pointAt(400, 114); + // It turns to the pointer, settles and blinks once, all before 3.2s. + expect(orb.play(0, 3.2)).toBeGreaterThan(0); + // Its next blink is 5.5s in at the soonest. + expect(orb.play(3.2, 4.4)).toBe(0); + // A theme flip is drawn once, though nothing else has moved. + document.documentElement.classList.add("dark"); + expect(orb.play(4.4, 4.8)).toBe(1); + pointAt(114, 400); + expect(orb.play(4.8, 5.4)).toBeGreaterThan(0); + } finally { + document.documentElement.classList.remove("dark"); + orb.done(); + } + }); + }); + + test("a small orb draws about thirty frames a second on a 120Hz screen, a large one every frame", () => { + withObserver(undefined, () => { + // A pointer circling the orb, so its eyes always have somewhere new to look. + const circle = (t: number) => + pointAt( + 114 + 200 * Math.cos(2 * Math.PI * t), + 114 + 200 * Math.sin(2 * Math.PI * t), + ); + const small = onScreen(28); + try { + const drawn = small.play(0, 1, 120, circle); + expect(drawn).toBeGreaterThanOrEqual(28); + expect(drawn).toBeLessThanOrEqual(31); + } finally { + small.done(); + } + const large = onScreen(56); + try { + expect(large.play(0, 1, 120, circle)).toBe(120); + } finally { + large.done(); + } + }); + }); + + test("an orb off screen is not drawn", () => { + withObserver(Sight, () => { + const ctx = fakeContext(); + const frames = manualFrames(); + const restore = withContext(ctx); + try { + const view = render(); + const orb = orbOf(view.container); + Sight.show(orb, false); + expect(frames.pending).toBe(0); + frames.run(); + expect(ctx.draws).toBe(0); + + Sight.show(orb, true); + expect(frames.pending).toBe(1); + frames.run(); + expect(ctx.draws).toBe(1); + } finally { + cleanup(); + frames.restore(); + restore(); + } + }); + }); + + test("nothing draws while the tab is hidden", () => { + withObserver(undefined, () => { + const ctx = fakeContext(); + const frames = manualFrames(); + const restore = withContext(ctx); + const hide = (hidden: boolean) => { + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => hidden, + }); + document.dispatchEvent(new Event("visibilitychange")); + }; + try { + render(); + expect(frames.pending).toBe(1); + hide(true); + expect(frames.pending).toBe(0); + frames.run(); + expect(ctx.draws).toBe(0); + + hide(false); + expect(frames.pending).toBe(1); + frames.run(); + expect(ctx.draws).toBe(1); + } finally { + Reflect.deleteProperty(document, "hidden"); + cleanup(); + frames.restore(); + restore(); + } + }); + }); + + test("renders with no canvas context", () => { + const frames = manualFrames(); + try { + const view = render( + , + ); + expect(view.container.querySelector("canvas")).not.toBeNull(); + // Nothing to draw on, so it asks for no frames. + expect(frames.requested).toBe(0); + } finally { + cleanup(); + frames.restore(); + } + }); +}); + +describe("its eyes", () => { + /** An orb 40 px across, centred at (120, 120). */ + const rect = { left: 100, top: 100, width: 40, height: 40 }; + const measure = () => rect; + const moveTo = (x: number, y: number) => + window.dispatchEvent( + new PointerEvent("pointermove", { clientX: x, clientY: y }), + ); + /** A second of frames at 30 a second, from `from` seconds on, and whether any of them perked up. */ + const watchFor = ( + gaze: ReturnType, + state: BotOrbState, + from = 0, + ) => { + let perked = false; + for (let i = 1; i <= 30; i++) { + perked = followPointer(gaze, from + i / 30, state, measure) || perked; + } + return perked; + }; + + test("turn to the pointer, fully at rest and partly while it works", () => { + const stop = subscribePointer(); + try { + moveTo(600, 120); + const idle = restingGaze(); + const thinking = restingGaze(); + watchFor(idle, "idle"); + watchFor(idle, "idle", 1); + watchFor(thinking, "thinking"); + watchFor(thinking, "thinking", 1); + // Far to its right: it looks right, level. + expect(idle.x).toBeGreaterThan(0.6); + expect(Math.abs(idle.y)).toBeLessThan(0.05); + expect(idle.w).toBeCloseTo(1, 2); + expect(thinking.w).toBeCloseTo(0.35, 2); + } finally { + stop(); + } + }); + + test("perk up once when the pointer comes close, and let go only well past it", () => { + const stop = subscribePointer(); + try { + moveTo(600, 120); + const gaze = restingGaze(); + expect(watchFor(gaze, "idle")).toBe(false); + + // Within 0.9 of its size of its centre. + moveTo(150, 120); + expect(watchFor(gaze, "idle", 1)).toBe(true); + expect(watchFor(gaze, "idle", 2)).toBe(false); + + // Out to 1.2 of its size: still close, no second perk. + moveTo(168, 120); + expect(watchFor(gaze, "idle", 3)).toBe(false); + moveTo(150, 120); + expect(watchFor(gaze, "idle", 4)).toBe(false); + + // Past 1.3 it lets go, and the next approach perks again. + moveTo(180, 120); + watchFor(gaze, "idle", 5); + moveTo(150, 120); + expect(watchFor(gaze, "idle", 6)).toBe(true); + } finally { + stop(); + } + }); + + test("a listening orb has already noticed you, and stops watching once the pointer leaves", () => { + const stop = subscribePointer(); + try { + moveTo(150, 120); + const gaze = restingGaze(); + expect(watchFor(gaze, "listening")).toBe(false); + expect(gaze.w).toBeGreaterThan(0.4); + + document.dispatchEvent( + new PointerEvent("pointerout", { bubbles: true, relatedTarget: null }), + ); + watchFor(gaze, "listening", 1); + watchFor(gaze, "listening", 2); + expect(gaze.w).toBeLessThan(0.01); + } finally { + stop(); + } + }); +}); + +describe("a Bot's face and OpenBot's mark", () => { + test("a Bot's avatar is named once and drawn as its orb", () => { + const view = render( + , + ); + const avatar = view.getByRole("img", { name: "Research desk" }); + expect(view.queryAllByRole("img")).toHaveLength(1); + expect(avatar.style.width).toBe("36px"); + const orb = orbOf(avatar); + expect(orb.getAttribute("aria-hidden")).toBe("true"); + expect(orb.dataset.palette).toBe(orbLookFor("research-desk").palette); + expect(orb.dataset.seed).toBe("research-desk"); + expect(orb.style.width).toBe("36px"); + expect(avatar.querySelector("canvas")).not.toBeNull(); + expect(avatar.querySelector("svg")).toBeNull(); + }); + + test("two Bots with different seeds can differ in colour, the same seed never does", () => { + const paletteOf = (seed: string) => { + const view = render(); + const palette = orbOf(view.container).dataset.palette; + cleanup(); + return palette; + }; + // Chosen to land on different colours: one pair in eight shares one. + expect(paletteOf("research-desk")).not.toBe(paletteOf("expense-review")); + expect(paletteOf("research-desk")).toBe(paletteOf("research-desk")); + }); + + test("OpenBot's mark is violet and maps every state", () => { + const states: Record = { + idle: "idle", + listening: "listening", + thinking: "thinking", + streaming: "thinking", + done: "done", + error: "idle", + }; + for (const [state, shown] of Object.entries(states)) { + const view = render(); + const orb = orbOf(view.container); + expect(orb.dataset.palette).toBe("violet"); + expect(orb.dataset.state).toBe(shown); + expect(orb.style.width).toBe("64px"); + cleanup(); + } + const view = render(); + expect(orbOf(view.container).style.width).toBe("192px"); + }); + + test("OpenBot's mark is drawn at a size in px, and refuses a share of its container", () => { + const view = render(); + expect(orbOf(view.container).style.width).toBe("27.5px"); + // Read as a number, "100%" would draw a 100px orb whatever its container is. + // @ts-expect-error Only a size in px type-checks. + expect(() => AgentOrb({ size: "100%" })).toThrow( + `AgentOrb's size is in px, as "64px", not "100%".`, + ); + }); +}); diff --git a/app/tests/channel-work-row.test.tsx b/app/tests/channel-work-row.test.tsx index a37164c9f9..d4a7ab4b62 100644 --- a/app/tests/channel-work-row.test.tsx +++ b/app/tests/channel-work-row.test.tsx @@ -56,7 +56,7 @@ test("says who was asked, with their avatar and no clock", () => { // The avatar is decoration beside a label that already names the Bot, so it is announced once. const avatar = row.querySelector("[aria-hidden='true'] [role='img']"); expect(avatar?.getAttribute("aria-label")).toBe("Ana"); - expect(row.querySelector("svg")).not.toBeNull(); + expect(row.querySelector("canvas")).not.toBeNull(); }); test("prefers the name and avatar the person knows the Bot by", () => { diff --git a/app/tests/settings-appearance.test.tsx b/app/tests/settings-appearance.test.tsx new file mode 100644 index 0000000000..e822beed02 --- /dev/null +++ b/app/tests/settings-appearance.test.tsx @@ -0,0 +1,102 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import type { UserPreferences } from "@openbot/shared/user-preferences"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ThemeProvider } from "@/components/theme-provider"; +import { authKeys } from "@/lib/auth/queries"; +import { userPreferencesQueryOptions } from "@/lib/settings/message-list"; +import { settingsKeys } from "@/lib/settings/queries"; +import { THEME_STORAGE_KEY } from "@/lib/theme"; +import { Route as SettingsRoute } from "@/routes/_authed/settings/index"; + +beforeAll(() => GlobalRegistrator.register()); +beforeEach(() => { + window.localStorage.clear(); + document.documentElement.classList.remove("dark"); +}); +afterEach(() => cleanup()); +afterAll(() => GlobalRegistrator.unregister()); + +async function renderSettings() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }); + queryClient.setQueryData(authKeys.currentUser(), { + id: "alice", + email: "alice@example.com", + name: "Alice", + role: "user", + onboarding: null, + }); + const preferences: UserPreferences = { + messageListEmphasis: "thread", + selfHostBannerDismissed: true, + narration: null, + skin: "openbot", + }; + queryClient.setQueryData( + userPreferencesQueryOptions("alice").queryKey, + preferences, + ); + queryClient.setQueryData(settingsKeys.instructions(), ""); + const rootRoute = createRootRoute({ + component: SettingsRoute.options.component, + }); + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const view = render( + + + + + , + ); + await view.findByText("Appearance"); + return view; +} + +test("Appearance offers System, Light and Dark where the dark switch was", async () => { + const view = await renderSettings(); + expect(view.queryByRole("switch", { name: "Dark theme" })).toBeNull(); + const appearance = view.getByRole("combobox", { name: "Appearance" }); + expect(appearance.textContent).toContain("System"); + + const user = userEvent.setup({ document }); + await user.click(appearance); + const options = (await view.findAllByRole("option")).map( + (option) => option.textContent, + ); + expect(options).toEqual(["System", "Light", "Dark"]); +}); + +test("choosing Dark draws it and remembers it", async () => { + const view = await renderSettings(); + const user = userEvent.setup({ document }); + await user.click(view.getByRole("combobox", { name: "Appearance" })); + await user.click(await view.findByRole("option", { name: "Dark" })); + + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark"); + expect( + view.getByRole("combobox", { name: "Appearance" }).textContent, + ).toContain("Dark"); +}); diff --git a/app/tests/skins-opendots-frame.test.tsx b/app/tests/skins-opendots-frame.test.tsx index 9cb46255b1..3a9bf4b000 100644 --- a/app/tests/skins-opendots-frame.test.tsx +++ b/app/tests/skins-opendots-frame.test.tsx @@ -19,7 +19,6 @@ import { } 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 { appConfig } from "@/lib/generated/application-config"; import { NAVIGATION, navigationFor } from "@/lib/navigation"; import { queryClient } from "@/query-client"; @@ -595,9 +594,13 @@ describe("OpenDots screens", () => { * deployment's own name from `brand.yaml`, and every Bot wears OpenBot's own avatar. */ describe("OpenDots skin wears the deployment's brand", () => { - /** What the drawing of one avatar looks like, without the ids React gives each instance. */ - const drawing = (root: Element | null) => - (root?.querySelector("svg")?.outerHTML ?? "").replace(/_r_[\w]+_/g, ""); + /* + * The seed one avatar's orb is drawn from, which decides everything about its look. Its colour + * alone would not do: eight colours go round, so a Bot's seed and its id share one an eighth of + * the time. + */ + const drawnFrom = (root: Element | null) => + root?.querySelector("[data-seed]")?.getAttribute("data-seed"); test("the frame names the product from brand.yaml and never says OpenDots or Dots", async () => { const { view } = draw("/channel/new?agent=ada"); @@ -636,23 +639,14 @@ describe("OpenDots skin wears the deployment's brand", () => { await view.findByRole("heading", { name: "Ada" }); const persona = view.container.querySelector("[data-opendots-persona]"); for (const bot of bots) { - const reference = render( - , - ); const row = within(list).getByText(bot.name).closest("a"); expect( row?.querySelector(`[role="img"][aria-label="${bot.name}"]`), ).toBeTruthy(); - expect(drawing(row)).toBe(drawing(reference.container)); // Drawn from the seed, not the id: the id would be a different Bot's face. - const fromId = render( - , - ); - expect(drawing(row)).not.toBe(drawing(fromId.container)); - reference.unmount(); - fromId.unmount(); + expect(drawnFrom(row)).toBe(bot.avatarSeed); + expect(drawnFrom(row)).not.toBe(bot.id); } - const ada = render(); - expect(drawing(persona)).toBe(drawing(ada.container)); + expect(drawnFrom(persona)).toBe("seed-ada"); }); }); diff --git a/app/tests/skins-openduet-frame.test.tsx b/app/tests/skins-openduet-frame.test.tsx index dca77f6989..b1aad4bab5 100644 --- a/app/tests/skins-openduet-frame.test.tsx +++ b/app/tests/skins-openduet-frame.test.tsx @@ -562,9 +562,11 @@ describe("the frame carries the deployment's brand, not OpenDuet's", () => { const menu = view.getByRole("dialog"); expect(within(menu).getByText(appConfig.brand.productName)).toBeTruthy(); expect(readable()).not.toContain("OpenDuet"); - // OpenBot's own orb, as sign-in and onboarding draw it, and no drawing of OpenDuet's. + // OpenBot's own orb, as sign-in and onboarding draw it, in OpenBot's violet. expect(orb.querySelector(".agent-orb")).not.toBeNull(); - expect(orb.querySelector("canvas")).toBeNull(); + expect( + orb.querySelector('.agent-orb[data-palette="violet"]'), + ).not.toBeNull(); }); test("Tasks names nothing as OpenDuet either", async () => { diff --git a/app/tests/skins-openmuse.test.tsx b/app/tests/skins-openmuse.test.tsx index b88cfaea57..ef11cc3e59 100644 --- a/app/tests/skins-openmuse.test.tsx +++ b/app/tests/skins-openmuse.test.tsx @@ -743,6 +743,66 @@ describe("the frame wears the deployment's brand, not OpenMuse's", () => { expect(readable()).not.toMatch(/openmuse/i); expect(capybaras()).toEqual([]); }); + + /* + * The face is a canvas drawn at a size in px, so no class can rescale it afterwards: one drawn + * bigger than its slot is clipped and sits off centre. The slot is 44px on a phone and 52px from + * 900px wide. + */ + test.each([ + [390, "44px"], + [1024, "52px"], + ])( + "%ipx wide, the centre face and the orb in it are drawn at %s", + async (width, size) => { + const viewport = ( + window as unknown as { + happyDOM: { + setViewport: (size: { width: number; height: number }) => void; + }; + } + ).happyDOM; + viewport.setViewport({ width, height: 844 }); + const drawnAt = (orb: HTMLElement | null) => [ + orb?.style.width, + orb?.style.height, + ]; + try { + serve(); + renderAt("/connections"); + const centre = await page().findByRole("button", { + name: /Switch Bot$/, + }); + const face = within(centre).getByRole("img", { name: "Muse" }); + expect(drawnAt(face)).toEqual([size, size]); + expect(drawnAt(face.querySelector("[data-palette]"))).toEqual([ + size, + size, + ]); + cleanup(); + + // With no Bot yet, the deployment's own mark fills the same slot. + const real = globalThis.fetch; + globalThis.fetch = Object.assign( + (input: RequestInfo | URL, init?: RequestInit) => + String(input) === "/api/agents" + ? Promise.resolve(Response.json({ agents: [] })) + : real(input, init), + { preconnect: originalFetch.preconnect }, + ); + renderAt("/s/activity"); + const empty = await page().findByRole("button", { + name: "Choose a Bot", + }); + expect(drawnAt(empty.querySelector(".agent-orb"))).toEqual([ + size, + size, + ]); + } finally { + viewport.setViewport({ width: 1024, height: 768 }); + } + }, + ); }); describe("every OpenBot surface stays reachable", () => { diff --git a/app/tests/theme-preference.test.ts b/app/tests/theme-preference.test.ts index bd1abf6763..9812b8c629 100644 --- a/app/tests/theme-preference.test.ts +++ b/app/tests/theme-preference.test.ts @@ -1,32 +1,291 @@ -import { describe, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act, cleanup, render } from "@testing-library/react"; +import { createElement } from "react"; +import { ThemeProvider, useTheme } from "../src/components/theme-provider"; import { - applyDarkTheme, - parseStoredDarkTheme, + applyTheme, + LEGACY_THEME_STORAGE_KEY, + parseThemePreference, + resolveTheme, + SYSTEM_DARK_QUERY, THEME_STORAGE_KEY, } from "../src/lib/theme"; +/** + * OpenBot follows the system unless the person chose otherwise: System, Light or Dark, with System + * the default, decided before the first paint. + */ describe("theme preference", () => { - test("only the stored dark value enables dark theme", () => { - expect(parseStoredDarkTheme("dark")).toBe(true); - expect(parseStoredDarkTheme("light")).toBe(false); - expect(parseStoredDarkTheme(null)).toBe(false); + test("nothing stored follows the system", () => { + expect(parseThemePreference(null)).toBe("system"); + expect(parseThemePreference("nonsense")).toBe("system"); }); - test("persists and applies the selected theme", () => { - const writes: Array<[string, string]> = []; - const toggles: Array<[string, boolean]> = []; - const schemes: Array = []; + test("a stored choice is kept", () => { + expect(parseThemePreference("light")).toBe("light"); + expect(parseThemePreference("dark")).toBe("dark"); + expect(parseThemePreference("system")).toBe("system"); + }); + + test("OpenBot's old switch counts only when it was turned on", () => { + // It wrote "light" on every load whether or not anybody chose it. + expect(parseThemePreference(null, "light")).toBe("system"); + expect(parseThemePreference(null, "dark")).toBe("dark"); + // A choice made since wins over it. + expect(parseThemePreference("light", "dark")).toBe("light"); + }); + + test("following the system draws what the system is set to", () => { + expect(resolveTheme("system", true)).toBe("dark"); + expect(resolveTheme("system", false)).toBe("light"); + expect(resolveTheme("light", true)).toBe("light"); + expect(resolveTheme("dark", false)).toBe("dark"); + }); - applyDarkTheme(true, { - setStoredValue: (key, value) => writes.push([key, value]), + test("drawing a theme sets the class and the colour scheme", () => { + const toggles: Array<[string, boolean]> = []; + const schemes: string[] = []; + applyTheme("dark", { + toggleRootClass: (name, force) => toggles.push([name, force]), + setRootColorScheme: (scheme) => schemes.push(scheme), + }); + applyTheme("light", { toggleRootClass: (name, force) => toggles.push([name, force]), setRootColorScheme: (scheme) => schemes.push(scheme), }); + expect(toggles).toEqual([ + ["dark", true], + ["dark", false], + ]); + expect(schemes).toEqual(["dark", "light"]); + }); +}); + +describe("pre-paint theme boot", () => { + const html = readFileSync(new URL("../index.html", import.meta.url), "utf8"); + const boot = html.match(/]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/); + + /** Runs the inline script against a stand-in document, storage and system setting. */ + function bootWith( + stored: Record, + systemDark: boolean, + storageThrows = false, + ) { + const classes = new Set(); + const style: { colorScheme?: string } = {}; + const queries: string[] = []; + runInNewContext(boot?.[1] ?? "", { + window: { + localStorage: { + getItem: (key: string) => { + if (storageThrows) throw new Error("denied"); + return stored[key] ?? null; + }, + }, + matchMedia: (query: string) => { + queries.push(query); + return { matches: systemDark }; + }, + }, + document: { + documentElement: { + classList: { + toggle: (name: string, force: boolean) => + force ? classes.add(name) : classes.delete(name), + }, + style, + }, + }, + }); + return { dark: classes.has("dark"), scheme: style.colorScheme, queries }; + } + + test("with nothing chosen it follows the system", () => { + expect(bootWith({}, true)).toMatchObject({ dark: true, scheme: "dark" }); + expect(bootWith({}, false)).toMatchObject({ dark: false, scheme: "light" }); + expect(bootWith({}, true).queries).toEqual([SYSTEM_DARK_QUERY]); + }); + + test("it reads the keys the app writes, and a choice beats the system", () => { + expect(bootWith({ [THEME_STORAGE_KEY]: "light" }, true).dark).toBe(false); + expect(bootWith({ [THEME_STORAGE_KEY]: "dark" }, false).dark).toBe(true); + expect(bootWith({ [THEME_STORAGE_KEY]: "system" }, true).dark).toBe(true); + expect(bootWith({ [LEGACY_THEME_STORAGE_KEY]: "dark" }, false).dark).toBe( + true, + ); + expect(bootWith({ [LEGACY_THEME_STORAGE_KEY]: "light" }, true).dark).toBe( + true, + ); + // The same rule as `lib/theme.ts` for everything either key can hold, so the app never redraws. + const held = [undefined, "system", "light", "dark", "nonsense"]; + for (const stored of held) + for (const legacy of held) + for (const systemDark of [true, false]) { + const keys: Record = {}; + if (stored) keys[THEME_STORAGE_KEY] = stored; + if (legacy) keys[LEGACY_THEME_STORAGE_KEY] = legacy; + const theme = resolveTheme( + parseThemePreference(stored ?? null, legacy ?? null), + systemDark, + ); + expect({ + keys, + systemDark, + dark: bootWith(keys, systemDark).dark, + }).toEqual({ keys, systemDark, dark: theme === "dark" }); + } + }); + + test("storage that throws still follows the system", () => { + expect(bootWith({}, true, true)).toMatchObject({ + dark: true, + scheme: "dark", + }); + expect(bootWith({}, false, true)).toMatchObject({ + dark: false, + scheme: "light", + }); + }); +}); + +/** + * The provider, once the app has loaded: it reads the keys the boot script read, follows the system + * live while the choice is System, and writes only the new key, only when somebody chooses. + */ +describe("the theme provider", () => { + beforeAll(() => GlobalRegistrator.register()); + afterAll(() => GlobalRegistrator.unregister()); + + /** A stand-in for the system's setting that a test can flip, as the system does at sunset. */ + let systemDark = false; + const listeners = new Set<() => void>(); + function setSystemDark(next: boolean) { + systemDark = next; + act(() => { + for (const listener of listeners) listener(); + }); + } + + let originalMatchMedia: typeof window.matchMedia; + beforeEach(() => { + systemDark = false; + listeners.clear(); + window.localStorage.clear(); + document.documentElement.classList.remove("dark"); + document.documentElement.style.colorScheme = ""; + originalMatchMedia = window.matchMedia; + window.matchMedia = ((query: string) => ({ + media: query, + get matches() { + return query === SYSTEM_DARK_QUERY && systemDark; + }, + addEventListener: (_type: string, listener: () => void) => + listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => + listeners.delete(listener), + })) as unknown as typeof window.matchMedia; + }); + afterEach(() => { + cleanup(); + window.matchMedia = originalMatchMedia; + }); + + let theme: ReturnType | undefined; + function Probe() { + theme = useTheme(); + return null; + } + function renderProvider() { + theme = undefined; + render(createElement(ThemeProvider, null, createElement(Probe))); + } + const drawn = () => ({ + dark: document.documentElement.classList.contains("dark"), + scheme: document.documentElement.style.colorScheme, + }); + + test("with nothing chosen it follows the system, live", () => { + renderProvider(); + expect(theme?.preference).toBe("system"); + expect(drawn()).toEqual({ dark: false, scheme: "light" }); + + setSystemDark(true); + expect(theme?.resolved).toBe("dark"); + expect(drawn()).toEqual({ dark: true, scheme: "dark" }); + + setSystemDark(false); + expect(drawn()).toEqual({ dark: false, scheme: "light" }); + }); + + test("a choice beats the system, and stops following it", () => { + renderProvider(); + act(() => theme?.setPreference("light")); + setSystemDark(true); + expect(drawn()).toEqual({ dark: false, scheme: "light" }); + + act(() => theme?.setPreference("dark")); + setSystemDark(false); + expect(drawn()).toEqual({ dark: true, scheme: "dark" }); + }); + + test("only a choice is written, and only under the new key", () => { + renderProvider(); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBeNull(); + expect(window.localStorage.getItem(LEGACY_THEME_STORAGE_KEY)).toBeNull(); + + act(() => theme?.setPreference("dark")); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark"); + expect(window.localStorage.getItem(LEGACY_THEME_STORAGE_KEY)).toBeNull(); + }); + + test("the old switch's Light lands on System, and its Dark stays Dark", () => { + window.localStorage.setItem(LEGACY_THEME_STORAGE_KEY, "light"); + systemDark = true; + renderProvider(); + expect(theme?.preference).toBe("system"); + expect(drawn().dark).toBe(true); + cleanup(); + + window.localStorage.setItem(LEGACY_THEME_STORAGE_KEY, "dark"); + systemDark = false; + renderProvider(); + expect(theme?.preference).toBe("dark"); + expect(drawn().dark).toBe(true); + }); + + test("storage that throws still follows the system after the app loads", () => { + const storage = Object.getOwnPropertyDescriptor(window, "localStorage"); + Object.defineProperty(window, "localStorage", { + configurable: true, + get() { + throw new Error("denied"); + }, + }); + try { + expect(() => window.localStorage).toThrow("denied"); + systemDark = true; + renderProvider(); + expect(theme?.preference).toBe("system"); + expect(drawn()).toEqual({ dark: true, scheme: "dark" }); - expect(writes).toEqual([[THEME_STORAGE_KEY, "dark"]]); - expect(toggles).toEqual([["dark", true]]); - expect(schemes).toEqual(["dark"]); + // The choice still holds for this visit; it just isn't remembered. + act(() => theme?.setPreference("light")); + expect(drawn()).toEqual({ dark: false, scheme: "light" }); + } finally { + if (storage) Object.defineProperty(window, "localStorage", storage); + else Reflect.deleteProperty(window, "localStorage"); + } }); }); diff --git a/app/tests/user-preferences.test.tsx b/app/tests/user-preferences.test.tsx index 16c93797f0..55424b01de 100644 --- a/app/tests/user-preferences.test.tsx +++ b/app/tests/user-preferences.test.tsx @@ -72,9 +72,14 @@ test("loads preferences from the account and renders the saved emphasis", async const { view } = setup(); const agent = await view.findByText("General Assistant"); expect(agent.className).toContain("text-[0.9rem]"); - expect(requests).toEqual([ - { path: "/api/settings/preferences", method: "GET", body: undefined }, - ]); + // Reads only: the preferences, and the Bots list the preview row's face is drawn from. + await waitFor(() => + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + "GET /api/settings/preferences", + "GET /api/agents", + ]), + ); + expect(requests.every((request) => request.body === undefined)).toBe(true); }); test("shows loading and read errors without enabling a save", async () => { diff --git a/app/tests/visual-system.test.ts b/app/tests/visual-system.test.ts new file mode 100644 index 0000000000..8effb73153 --- /dev/null +++ b/app/tests/visual-system.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { cn, TYPE_SCALE_CLASSES } from "../src/lib/utils"; + +/** + * OpenBot's visual system, carried by the variables every screen already reads, so these + * tests read the stylesheet itself: every variable has a value in both themes, the colours that + * carry words clear their contrast floor on the grounds they sit on, and the type scale is five + * sizes in rem. That is `theme.css`, which the app and the Recorder's pages both import. + */ + +const styles = readFileSync( + new URL("../src/theme.css", import.meta.url), + "utf8", +); + +/** The custom properties declared directly inside the first block that opens with `selector {`. */ +function block(selector: string): Map { + const start = styles.indexOf(`${selector} {`); + if (start === -1) throw new Error(`no ${selector} block`); + let depth = 0; + let end = start; + for (let at = styles.indexOf("{", start); at < styles.length; at++) { + if (styles[at] === "{") depth++; + if (styles[at] === "}") depth--; + if (depth === 0) { + end = at; + break; + } + } + const body = styles.slice(start, end).replace(/\/\*[\s\S]*?\*\//g, ""); + const variables = new Map(); + for (const match of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) { + variables.set( + match[1] as string, + (match[2] as string).replace(/\s+/g, " "), + ); + } + return variables; +} + +const light = block(":root"); +const dark = block(".dark"); + +/** A variable's value, following `var(--x)` within the same theme. */ +function resolve(theme: Map, name: string): string { + const fallback = theme === dark ? light : undefined; + let value = theme.get(name) ?? fallback?.get(name); + for (let hops = 0; value?.startsWith("var(") && hops < 5; hops++) { + const inner = value.slice(4, -1); + value = theme.get(inner) ?? fallback?.get(inner); + } + if (!value) throw new Error(`${name} has no value`); + return value; +} + +function luminance(hex: string): number { + if (!/^#[0-9a-f]{6}$/i.test(hex)) throw new Error(`${hex} is not #rrggbb`); + const channels = [1, 3, 5].map((at) => { + const c = Number.parseInt(hex.slice(at, at + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return ( + 0.2126 * (channels[0] ?? 0) + + 0.7152 * (channels[1] ?? 0) + + 0.0722 * (channels[2] ?? 0) + ); +} + +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return ((hi ?? 0) + 0.05) / ((lo ?? 0) + 0.05); +} + +const themes = [ + ["light", light], + ["dark", dark], +] as const; + +describe("light and dark", () => { + test("every variable the light theme defines has a dark value", () => { + const aliases = [...light.entries()] + .filter(([, value]) => value.startsWith("var(--")) + .map(([name]) => name); + const missing = [...light.keys()].filter( + (name) => + !dark.has(name) && + !aliases.includes(name) && + // Shape and motion are the same in both themes. + !/^--(radius|target|ease|dur)/.test(name), + ); + expect(missing).toEqual([]); + for (const name of ["--glass", "--veil", "--shadow-glass"]) + expect(dark.has(name)).toBe(true); + }); + + test("the visual system's roles arrive under their own names", () => { + const roles = [ + "--brand", + "--brand-foreground", + ...["done", "needs", "working", "problem"].flatMap((status) => [ + `--status-${status}`, + `--status-${status}-soft`, + ]), + "--glass", + "--veil", + "--shadow-float", + "--shadow-card", + "--shadow-raised", + "--shadow-glass", + "--ease-out", + "--ease-in", + "--dur-tap", + "--dur-swap", + "--dur-enter", + "--dur-sheet", + ]; + expect(roles.filter((name) => !light.has(name))).toEqual([]); + expect(light.get("--target")).toBe("44px"); + expect(light.get("--radius")).toBe("0.55rem"); + expect(styles).not.toContain("--duet-"); + }); + + test("the brand is for focus, selection and links; shadcn's accent stays a neutral fill", () => { + for (const [, theme] of themes) { + expect(resolve(theme, "--ring")).toBe(resolve(theme, "--brand")); + expect(resolve(theme, "--sidebar-primary")).toBe( + resolve(theme, "--brand"), + ); + expect(resolve(theme, "--accent")).toBe(resolve(theme, "--secondary")); + } + }); +}); + +describe("contrast, checked in both themes", () => { + const grounds = [ + "--background", + "--card", + "--popover", + "--secondary", + "--muted", + "--accent", + "--sidebar", + "--sidebar-accent", + ]; + for (const [themeName, theme] of themes) { + test(`text roles clear 4.5:1 on every ground, ${themeName}`, () => { + for (const ink of [ + "--foreground", + "--muted-foreground", + "--card-foreground", + "--popover-foreground", + "--secondary-foreground", + "--accent-foreground", + "--sidebar-foreground", + "--sidebar-accent-foreground", + "--brand", + "--destructive", + "--success", + "--status-done", + "--status-needs", + "--status-working", + "--status-problem", + ]) + for (const ground of grounds) + expect({ + ink, + ground, + clears: + contrast(resolve(theme, ink), resolve(theme, ground)) >= 4.5, + }).toEqual({ ink, ground, clears: true }); + }); + + test(`each status word clears 4.5:1 on its own soft fill, ${themeName}`, () => { + for (const status of ["done", "needs", "working", "problem"]) + expect({ + status, + ratio: + contrast( + resolve(theme, `--status-${status}`), + resolve(theme, `--status-${status}-soft`), + ) >= 4.5, + }).toEqual({ status, ratio: true }); + }); + } + + test("what sits on the primary clears 4.5:1", () => { + for (const [themeName, theme] of themes) + for (const [on, fill] of [ + ["--primary-foreground", "--primary"], + ["--brand-foreground", "--brand"], + ["--sidebar-primary-foreground", "--sidebar-primary"], + ]) + expect({ + themeName, + on, + clears: + contrast( + resolve(theme, on as string), + resolve(theme, fill as string), + ) >= 4.5, + }).toEqual({ themeName, on, clears: true }); + }); +}); + +describe("the type scale", () => { + const rules = styles.replace(/\/\*[\s\S]*?\*\//g, ""); + + test("the root font size is the browser's", () => { + expect(rules).not.toMatch(/html\s*\{\s*font-size/); + // OpenBot's 15px scale is gone: no `text-*` size is redefined, and body text is not pinned. + expect(rules).not.toMatch(/--text-[\w-]+\s*:/); + expect(rules).not.toMatch(/body\s*\{[^}]*@apply[^;]*\btext-base\b/); + }); + + test("five sizes, in rem", () => { + const sizes = new Map(); + for (const match of styles.matchAll( + /@utility (type-[\w-]+)\s*\{\s*font-size:\s*([^;]+);/g, + )) + sizes.set(match[1] as string, match[2] as string); + expect(Object.fromEntries(sizes)).toEqual({ + "type-display": "1.5rem", + "type-nav": "1.125rem", + "type-body": "1.0625rem", + "type-sub": "0.9375rem", + "type-caption": "0.8125rem", + }); + }); + + test("Inter's single-storey a and flat-topped 3 are on for every screen", () => { + expect(rules).toMatch( + /@utility face\s*\{\s*font-feature-settings:\s*"cv11",\s*"ss03";/, + ); + expect(rules).toMatch(/body\s*\{[^}]*@apply[^;]*\bface\b/); + }); +}); + +describe("merging classes", () => { + test("a type size given later replaces a primitive's text size and leading", () => { + for (const size of TYPE_SCALE_CLASSES) + expect(cn("px-3 text-sm leading-relaxed", size)).toBe(`px-3 ${size}`); + expect(cn("text-base font-medium", "type-display font-semibold")).toBe( + "type-display font-semibold", + ); + }); + + test("a colour does not knock out a type size", () => { + expect(cn("type-sub text-muted-foreground", "text-foreground")).toBe( + "type-sub text-foreground", + ); + }); + + test("every type utility in the stylesheet is known to the merge", () => { + const declared = [...styles.matchAll(/@utility (type-[\w-]+)/g)].map( + (match) => match[1], + ); + expect(new Set(declared)).toEqual(new Set(TYPE_SCALE_CLASSES)); + }); +}); diff --git a/bun.lock b/bun.lock index 64588375bc..c1004715e4 100644 --- a/bun.lock +++ b/bun.lock @@ -34,7 +34,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", @@ -1114,8 +1113,6 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "boring-avatars": ["boring-avatars@2.0.4", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw=="], - "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], diff --git a/docs/configuration.md b/docs/configuration.md index 9794cf2615..46d9366b41 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1118,6 +1118,14 @@ agents: Each agent requires `id`, `name`, `title`, `role_description`, and `type`. +`avatar_seed` is optional and picks the Bot's face; without one the seed is the agent's `id`. A +seed may start with one of the eight Bot colours and a colon to draw in that colour: `ember`, +`moss`, `sun`, `berry`, `ocean`, `sky`, `coral` or `slate`, as in `avatar_seed: moss:knowledge`. The +rest of the face still comes from the whole seed, so two Bots in one colour still look different. +Any other seed takes its colour from a hash of the seed, as before. `violet:` names no colour, +because violet is OpenBot's own mark. A package's Bots keep the look the package gives them; their +owners choose a look for the Bots they make on each Bot's Setup page. + | Type | Required field | | --------------- | --------------- | | `built-in` | `system_prompt` | diff --git a/extension/src/content/notice.ts b/extension/src/content/notice.ts index 95e274d4cd..80c89077b5 100644 --- a/extension/src/content/notice.ts +++ b/extension/src/content/notice.ts @@ -10,34 +10,34 @@ export const NOTICE_TAG = "openbot-recorder-notice"; export const NOTICE_TOKENS = { 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)", - "--border": "oklch(0.922 0 0)", - "--primary": "oklch(0.205 0 0)", - "--primary-foreground": "oklch(0.985 0 0)", - "--secondary": "oklch(0.97 0 0)", - "--muted-foreground": "oklch(0.5 0 0)", - "--destructive": "oklch(0.577 0.245 27.325)", - "--ring": "oklch(0.708 0 0)", + "--background": "#f6f6f8", + "--foreground": "#18181b", + "--card": "#ffffff", + "--card-foreground": "#18181b", + "--border": "rgb(24 24 27 / 9%)", + "--primary": "#111114", + "--primary-foreground": "#ffffff", + "--secondary": "#f1f1f4", + "--muted-foreground": "#6b6b75", + "--destructive": "#b91c1c", + "--ring": "#6430ab", }, 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)", - "--border": "oklch(1 0 0 / 10%)", - "--primary": "oklch(0.922 0 0)", - "--primary-foreground": "oklch(0.205 0 0)", - "--secondary": "oklch(0.269 0 0)", - "--muted-foreground": "oklch(0.708 0 0)", - "--destructive": "oklch(0.704 0.191 22.216)", - "--ring": "oklch(0.556 0 0)", + "--background": "#111113", + "--foreground": "#f4f4f5", + "--card": "#1c1c1f", + "--card-foreground": "#f4f4f5", + "--border": "rgb(255 255 255 / 10%)", + "--primary": "#f4f4f5", + "--primary-foreground": "#111114", + "--secondary": "#26262b", + "--muted-foreground": "#8e8e98", + "--destructive": "#f87171", + "--ring": "#b49ae8", }, } as const; -export const RADIUS = "0.5rem"; +export const RADIUS = "0.55rem"; type Choice = "stop" | "restart" | "continue"; diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index fc5abebfa8..490e16d7d7 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -1,4 +1,4 @@ -import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm"; +import { and, asc, eq, isNotNull, isNull, or, sql } from "drizzle-orm"; import { capabilityRefusal } from "../admin/capabilities"; import type { CredentialStore } from "../credentials"; import type { Database } from "../db/client"; @@ -30,6 +30,7 @@ import type { AgentActor, AgentProfile, CreateAgentInput, + UpdateAgentInput, } from "./profile-types"; type Transaction = Parameters[0]>[0]; @@ -75,7 +76,7 @@ export type AgentProfileStore = { update( actor: AgentActor, id: string, - input: CreateAgentInput, + input: UpdateAgentInput, ): Promise; duplicate(actor: AgentActor, id: string): Promise; setHidden(actor: AgentActor, id: string, hidden: boolean): Promise; @@ -486,15 +487,23 @@ export function createAgentProfileStore( return { async list(actor, hidden = false) { - const rows = await joinedProfiles(database, actor).where( - and( - isNull(agentProfiles.deletedAt), - accessFilter(actor), - hidden - ? isNotNull(agentPreferences.hiddenAt) - : isNull(agentPreferences.hiddenAt), - ), - ); + /* + * In the order the Bots were made, the id breaking a tie (a tenant package writes its Bots in + * one transaction, so they share a moment). Without an order Postgres answers in whatever + * order the rows lie in, and an edit writes a new row: a Bot that was restyled or renamed + * moved to the end. The app lists Bots in this order and takes the first as the default. + */ + const rows = await joinedProfiles(database, actor) + .where( + and( + isNull(agentProfiles.deletedAt), + accessFilter(actor), + hidden + ? isNotNull(agentPreferences.hiddenAt) + : isNull(agentPreferences.hiddenAt), + ), + ) + .orderBy(asc(agents.createdAt), asc(agents.id)); return rows.map(mapProfile); }, @@ -689,6 +698,10 @@ export function createAgentProfileStore( ...(input.isolated === undefined ? {} : { isolated: input.isolated }), + // The same: absent keeps its face. The route has checked the spelling. + ...(input.avatarSeed === undefined + ? {} + : { avatarSeed: input.avatarSeed }), updatedAt, }) .where(eq(agentProfiles.agentId, id)); diff --git a/server/src/agents/profile-types.ts b/server/src/agents/profile-types.ts index 45c2e391ed..735fecd3f9 100644 --- a/server/src/agents/profile-types.ts +++ b/server/src/agents/profile-types.ts @@ -68,3 +68,11 @@ export type CreateAgentInput = Pick< */ isolated?: boolean; }; + +/** + * An edit to a Bot: everything a create takes, and optionally a new look. + * + * `avatarSeed` is the Bot's face (`@openbot/shared/bot-look`). Absent leaves it as it is, so an edit + * to anything else never changes how the Bot looks. Not on create, where the seed is the Bot's id. + */ +export type UpdateAgentInput = CreateAgentInput & { avatarSeed?: string }; diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index b99b87c32d..77d934f8fe 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -1,3 +1,4 @@ +import { avatarSeedProblem } from "@openbot/shared/bot-look"; import type { Context, MiddlewareHandler } from "hono"; import { Hono } from "hono"; import type { AuditEventType, AuditStore } from "../audit"; @@ -37,6 +38,7 @@ type AgentInputObject = { endpoint?: unknown; auth?: unknown; isolated?: unknown; + avatarSeed?: unknown; }; /** @@ -149,6 +151,23 @@ export function parseAgentInput( }; } +/** + * A new look for a Bot, when an edit brings one; undefined leaves its face as it is. + * + * Checked by the rules the orb draws by (`@openbot/shared/bot-look`): a short safe token, and when + * it names a colour, one a Bot can have. Never violet, which is OpenBot's own. + */ +function parseAvatarSeed( + input: unknown, +): { ok: true; value: string | undefined } | { ok: false; error: string } { + const seed = isAgentInputObject(input) ? input.avatarSeed : undefined; + if (seed === undefined) return { ok: true, value: undefined }; + const problem = avatarSeedProblem(seed); + return problem === null + ? { ok: true, value: seed as string } + : { ok: false, error: problem }; +} + function isAgentInputObject(input: unknown): input is AgentInputObject { return typeof input === "object" && input !== null && !Array.isArray(input); } @@ -678,12 +697,12 @@ export function createAgentRoutes( routes.patch("/:agentId", requireUser, async (context) => { // Malformed JSON is a recoverable client-input error and is validated by the same parser. - const parsed = parseAgentInput( - await context.req.json().catch(() => null), - allowPrivateHosts, - allowedHosts, - ); + const body = await context.req.json().catch(() => null); + const parsed = parseAgentInput(body, allowPrivateHosts, allowedHosts); if (!parsed.ok) return context.json({ error: parsed.error }, 400); + // Changed by whoever may change the rest of the profile: the store's manage check covers both. + const look = parseAvatarSeed(body); + if (!look.ok) return context.json({ error: look.error }, 400); if (parsed.value.isolated === true && !isolationAvailable) { return refuseIsolation( context, @@ -698,7 +717,10 @@ export function createAgentRoutes( const agent = await store.update( context.var.actor, context.req.param("agentId"), - parsed.value, + { + ...parsed.value, + ...(look.value === undefined ? {} : { avatarSeed: look.value }), + }, ); /* * What changed, not the new values. Repointing the endpoint is the dangerous edit and is worth @@ -722,6 +744,8 @@ export function createAgentRoutes( ...(parsed.value.isolated === undefined ? {} : { isolated: parsed.value.isolated }), + // The same for a new look. It is a picture, not a secret, so the trail names it. + ...(look.value === undefined ? {} : { avatarSeed: look.value }), }); /* * Publishing is the owner's call, and a wider audience can outrun what an administrator diff --git a/server/tests/agent-profile-store.integration.test.ts b/server/tests/agent-profile-store.integration.test.ts index 1511ac061a..0198c4a3f1 100644 --- a/server/tests/agent-profile-store.integration.test.ts +++ b/server/tests/agent-profile-store.integration.test.ts @@ -475,7 +475,6 @@ describe("agent profile store integration", () => { // reaches here already validated by the same check that guards creation. endpoint: "https://moved.example.test/ag-ui", ownerUserId: "forged-owner", - avatarSeed: "forged-avatar", packageId: deploymentPackage.id, deletedAt: new Date(), } as unknown as CreateAgentInput); @@ -522,6 +521,96 @@ describe("agent profile store integration", () => { ); }); + test("an edit changes a Bot's look only when it brings one, and only for whoever may manage it", async () => { + const owner = await createUser(); + const other = await createUser(); + const admin = await createUser("admin"); + const source = await createProfileFixture({ owner, visibility: "public" }); + const input: CreateAgentInput = { + name: source.name, + title: source.title, + roleDescription: source.roleDescription, + visibility: "public", + }; + const seedOf = async () => + (await profileById(owner, source.agentId)).avatarSeed; + + expect( + ( + await store.update(owner, source.agentId, { + ...input, + avatarSeed: "moss:k2p9x7aq", + }) + ).avatarSeed, + ).toBe("moss:k2p9x7aq"); + // An edit to anything else keeps the face. + await store.update(owner, source.agentId, { ...input, title: "Retitled" }); + expect(await seedOf()).toBe("moss:k2p9x7aq"); + + // The same rule as a rename: somebody who may use the Bot but not manage it can't restyle it. + await expect( + store.update(other, source.agentId, { ...input, avatarSeed: "sun:x" }), + ).rejects.toBeInstanceOf(AgentNotManageableError); + expect(await seedOf()).toBe("moss:k2p9x7aq"); + + // An admin may, as an admin may rename it. + await store.update(admin, source.agentId, { + ...input, + avatarSeed: "sky:x", + }); + expect(await seedOf()).toBe("sky:x"); + + // And nobody may restyle a Bot a tenant package owns: its look is the package's. + const deploymentPackage = await createPackage(); + const packaged = await createProfileFixture({ + owner, + packageId: deploymentPackage.id, + }); + await expect( + store.update(admin, packaged.agentId, { ...input, avatarSeed: "sun:x" }), + ).rejects.toBeInstanceOf(ProtectedAgentError); + expect((await profileById(admin, packaged.agentId)).avatarSeed).toBe( + packaged.avatarSeed, + ); + }); + + /* + * The list had no order of its own, so Postgres answered in whatever order its rows lay in, and an + * edit writes a new row: a Bot somebody restyled or renamed jumped from first to last in Yours. + * The app shows Bots in the order the server sends them and takes the first as the default, so + * the order is the store's to keep: the order they were made in. + */ + test("lists Bots in the order they were made, and an edit moves none of them", async () => { + const owner = await createUser(); + const made: string[] = []; + for (const label of ["First", "Second", "Third"]) { + const created = await store.create(owner, { + name: `${label} ${randomUUID()}`, + title: "In order", + roleDescription: "Keeps its place.", + visibility: "private", + }); + createdAgentIds.push(created.id); + made.push(created.id); + } + const listed = async () => + (await store.list(owner)) + .map((profile) => profile.id) + .filter((agentId) => made.includes(agentId)); + expect(await listed()).toEqual(made); + + // A new look and a new name for the first, which writes both of its rows again. + const first = await profileById(owner, made[0] as string); + await store.update(owner, first.id, { + name: `Renamed ${randomUUID()}`, + title: first.title, + roleDescription: first.roleDescription, + visibility: first.visibility, + avatarSeed: "moss:k2p9x7aq", + }); + expect(await listed()).toEqual(made); + }); + test("rejects public non-owner mutation as unmanageable and inaccessible private mutation as absent", async () => { const owner = await createUser(); const other = await createUser(); diff --git a/server/tests/agent-routes.test.ts b/server/tests/agent-routes.test.ts index 1a84adaf82..ea01cabaa8 100644 --- a/server/tests/agent-routes.test.ts +++ b/server/tests/agent-routes.test.ts @@ -509,11 +509,12 @@ describe("agent lifecycle routes", () => { visibility: " private ", id: "forged-agent", ownerUserId: "attacker", - avatarSeed: "forged-avatar", deletedAt: "now", systemOwned: true, // A real field now, not a forged one; the rest of this list still is. endpoint: "https://agents.example.com/ag-ui", + // Real on an edit, which may change how the Bot looks, and forged on a create, which may not. + avatarSeed: "moss:k2p9x7aq", }; for (const [path, method] of [ @@ -535,10 +536,82 @@ describe("agent lifecycle routes", () => { }; expect(store.calls).toEqual([ ["create", actor, expected], - ["update", actor, "agent-1", expected], + [ + "update", + actor, + "agent-1", + { ...expected, avatarSeed: "moss:k2p9x7aq" }, + ], + ]); + }); + + test("an edit may bring a new look, and one that leaves it out keeps the Bot's face", async () => { + const store = fakeStore(); + const app = appFor(store); + const patch = (body: Record) => + app.request("http://openbot.test/agent-1", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...validInput, ...body }), + }); + + const recoloured = await patch({ avatarSeed: "ember:k2p9x7aq" }); + expect(recoloured.status).toBe(200); + expect((await json(recoloured)).agent.avatarSeed).toBe("ember:k2p9x7aq"); + // A seed that names no colour is still a look, as every Bot's own id is. + expect((await patch({ avatarSeed: "agent_3f6c-91" })).status).toBe(200); + expect((await patch({ title: "Finance" })).status).toBe(200); + + expect(store.calls.map((call) => call[3])).toEqual([ + { ...validInput, avatarSeed: "ember:k2p9x7aq" }, + { ...validInput, avatarSeed: "agent_3f6c-91" }, + { ...validInput, title: "Finance" }, ]); }); + test.each([ + ["violet:k2p9x7aq", "Violet is kept for OpenBot. Pick another colour."], + [ + "purple:k2p9x7aq", + "That isn't a colour a Bot can have. Pick one of the eight.", + ], + [ + "moss:", + "A Bot's look is 1 to 64 letters, numbers, hyphens, underscores or colons.", + ], + [ + `moss:${"x".repeat(60)}`, + "A Bot's look is 1 to 64 letters, numbers, hyphens, underscores or colons.", + ], + [ + "", + "A Bot's look is 1 to 64 letters, numbers, hyphens, underscores or colons.", + ], + [ + null, + "A Bot's look is 1 to 64 letters, numbers, hyphens, underscores or colons.", + ], + [ + 42, + "A Bot's look is 1 to 64 letters, numbers, hyphens, underscores or colons.", + ], + ])("refuses the look %p and changes nothing", async (avatarSeed, error) => { + const store = fakeStore(); + + const response = await appFor(store).request( + "http://openbot.test/agent-1", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...validInput, avatarSeed }), + }, + ); + + expect(response.status).toBe(400); + expect(await json(response)).toEqual({ error }); + expect(store.calls).toEqual([]); + }); + test.each([ ["POST", "/"], ["PATCH", "/agent-1"], diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 236f1d8042..86d78af0e7 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -1010,16 +1010,6 @@ const ALLOW: Allowed[] = [ expression: "expect(run?.forwardedProps?.openbotRun).toBeUndefined()", finding: "NEW (round 2 did not find this)", }, - { - file: "app/tests/user-preferences.test.tsx", - shape: "c", - context: [ - '"loads preferences from the account and renders the saved emphasis"', - ], - expression: - 'expect(requests).toEqual([{path:"/api/settings/preferences",method:"GET",body:undefined},])', - finding: "NEW (round 2 did not find this)", - }, { file: "server/tests/agent-handoff.test.ts", shape: "c", diff --git a/server/tests/bot-lifecycle-audit.test.ts b/server/tests/bot-lifecycle-audit.test.ts index f446e73b6b..6473b37820 100644 --- a/server/tests/bot-lifecycle-audit.test.ts +++ b/server/tests/bot-lifecycle-audit.test.ts @@ -134,6 +134,23 @@ describe("what a Bot is, on the trail", () => { expect(rows[0]?.payload.visibility).toBe("private"); }); + test("a new look is recorded, and an edit without one says nothing about it", async () => { + const { rows, hono } = app(); + + await hono.request("http://t/api/agents/bot-1", { + ...json({ avatarSeed: "coral:k2p9x7aq" }), + method: "PATCH", + }); + await hono.request("http://t/api/agents/bot-1", { + ...json({ title: "Sales assistant, revised" }), + method: "PATCH", + }); + + expect(rows[0]?.eventType).toBe("bot.updated"); + expect(rows[0]?.payload.avatarSeed).toBe("coral:k2p9x7aq"); + expect(rows[1]?.payload).not.toHaveProperty("avatarSeed"); + }); + test("a replaced key is noted and never recorded", async () => { const { rows, hono } = app(); diff --git a/shared/bot-look.test.ts b/shared/bot-look.test.ts new file mode 100644 index 0000000000..3973d27c9b --- /dev/null +++ b/shared/bot-look.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { + avatarSeedProblem, + BOT_COLOURS, + namedColour, + newFace, + recoloured, + seedFor, +} from "./bot-look"; + +describe("a seed that names its colour", () => { + test("names one of the eight, and never violet", () => { + expect(BOT_COLOURS).toHaveLength(8); + expect(BOT_COLOURS).not.toContain("violet" as never); + for (const colour of BOT_COLOURS) { + expect(namedColour(seedFor(colour, "k2p9x7aq"))).toBe(colour); + } + expect(namedColour("violet:k2p9x7aq")).toBeNull(); + }); + + test("any other seed names no colour", () => { + for (const seed of [ + "research-desk", + "agent_3f6c", + "", + "purple:k2p9x7aq", + "Moss:k2p9x7aq", + ":moss", + ]) { + expect(namedColour(seed)).toBeNull(); + } + }); +}); + +describe("what the server accepts as a look", () => { + test("a short safe token, named colour or not", () => { + expect(avatarSeedProblem("research-desk")).toBeNull(); + expect(avatarSeedProblem(`agent_${crypto.randomUUID()}`)).toBeNull(); + expect(avatarSeedProblem("moss:k2p9x7aq")).toBeNull(); + expect(avatarSeedProblem("x".repeat(64))).toBeNull(); + }); + + test("refuses anything else in a plain sentence", () => { + for (const seed of [ + "", + "x".repeat(65), + "moss:k2 p9", + "