From 666c1ff417e021304f6afececb90c97bbaea3b34 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 13:52:49 -0700 Subject: [PATCH 01/18] feat(theme): follow the system's dark mode, with an Appearance setting --- app/index.html | 24 +- app/src/components/theme-provider.tsx | 66 ++++- app/src/lib/theme.ts | 68 ++++- app/src/routes/_authed/settings/index.tsx | 44 +++- app/tests/settings-appearance.test.tsx | 101 ++++++++ app/tests/theme-preference.test.ts | 291 ++++++++++++++++++++-- 6 files changed, 543 insertions(+), 51 deletions(-) create mode 100644 app/tests/settings-appearance.test.tsx 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/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/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/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/tests/settings-appearance.test.tsx b/app/tests/settings-appearance.test.tsx new file mode 100644 index 0000000000..bd3ae47ab7 --- /dev/null +++ b/app/tests/settings-appearance.test.tsx @@ -0,0 +1,101 @@ +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, + }; + 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/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"); + } }); }); From 3306a54a3cdfbaf99d3fea3832c77e78fa14af29 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 13:59:37 -0700 Subject: [PATCH 02/18] feat(ui): a visual system of cool greys, one accent and five type sizes --- .claude/skills/openbot-screen-layout/SKILL.md | 10 +- .../channels/composer/attachment-strip.tsx | 4 +- app/src/lib/utils.ts | 28 +- app/src/theme.css | 279 +++++++++++++----- app/tests/visual-system.test.ts | 260 ++++++++++++++++ extension/src/content/notice.ts | 46 +-- 6 files changed, 525 insertions(+), 102 deletions(-) create mode 100644 app/tests/visual-system.test.ts 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/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/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/theme.css b/app/src/theme.css index 8e30caa8b0..acbc4e706b 100644 --- a/app/src/theme.css +++ b/app/src/theme.css @@ -1,76 +1,150 @@ @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`, for focus, + * selection and links and never for headings (shadcn's `--accent` stays the neutral hover fill); + * four status colours for how work stands. 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 +176,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 +222,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 +304,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/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/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"; From c4d5ff31c0bded23613f32538c63beaf325f3d44 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 13:48:28 -0700 Subject: [PATCH 03/18] feat(orb): an orb drawn on a canvas, in a colour for each Bot --- app/src/components/agents/orb/bot-orb-draw.ts | 701 ++++++++++++++++++ app/tests/bot-orb.test.tsx | 193 +++++ 2 files changed, 894 insertions(+) create mode 100644 app/src/components/agents/orb/bot-orb-draw.ts create mode 100644 app/tests/bot-orb.test.tsx 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..d1605ae02c --- /dev/null +++ b/app/src/components/agents/orb/bot-orb-draw.ts @@ -0,0 +1,701 @@ +/** + * 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 + * is picked from its seed, and OpenBot's own is violet. 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. A Bot's is `orbPaletteFor` its seed, which picks by position in `ORB_PALETTES`: + * add a colour at the end, never reorder them, or every Bot changes colour. + */ +const PALETTES = { + violet: { + main: [0.7, 0.14, 288], + side: [0.72, 0.15, 318], + deep: [0.47, 0.2, 296], + pale: [0.86, 0.08, 308], + base: [0.93, 0.03, 300], + shade: [40, 16, 78], + }, + ember: { + main: [0.79, 0.13, 62], + side: [0.75, 0.15, 35], + deep: [0.53, 0.17, 30], + pale: [0.89, 0.08, 52], + base: [0.94, 0.03, 50], + shade: [84, 30, 14], + }, + moss: { + main: [0.77, 0.11, 182], + side: [0.78, 0.13, 152], + deep: [0.5, 0.12, 165], + pale: [0.89, 0.07, 160], + base: [0.94, 0.03, 160], + shade: [14, 60, 40], + }, + sun: { + main: [0.81, 0.15, 72], + side: [0.85, 0.14, 92], + deep: [0.57, 0.14, 58], + pale: [0.93, 0.08, 92], + base: [0.95, 0.03, 90], + shade: [92, 58, 10], + }, + berry: { + main: [0.7, 0.15, 328], + side: [0.72, 0.16, 352], + deep: [0.48, 0.18, 345], + pale: [0.86, 0.08, 350], + base: [0.93, 0.03, 350], + shade: [80, 14, 48], + }, + ocean: { + main: [0.74, 0.13, 205], + side: [0.75, 0.13, 232], + deep: [0.53, 0.2, 262], + pale: [0.86, 0.08, 220], + base: [0.93, 0.025, 235], + shade: [14, 30, 80], + }, + // Beside ocean on the wheel, so lighter and softer: a clear sky against ocean's deep water. + sky: { + main: [0.79, 0.11, 220], + side: [0.8, 0.1, 242], + deep: [0.55, 0.15, 245], + pale: [0.9, 0.06, 228], + base: [0.95, 0.02, 230], + shade: [10, 40, 76], + }, + coral: { + main: [0.78, 0.13, 32], + side: [0.74, 0.15, 8], + deep: [0.52, 0.17, 18], + pale: [0.88, 0.08, 22], + base: [0.94, 0.03, 20], + shade: [84, 20, 24], + }, + // Nearly grey: for a team big enough to want a quiet one. + slate: { + main: [0.72, 0.045, 232], + side: [0.76, 0.04, 250], + deep: [0.48, 0.05, 265], + pale: [0.88, 0.02, 255], + base: [0.93, 0.012, 258], + shade: [30, 38, 54], + }, +} as const satisfies Record; + +export type OrbPaletteName = keyof typeof PALETTES; + +/** Every colour, in the order `orbPaletteFor` picks from. */ +export const ORB_PALETTES = Object.keys(PALETTES) as readonly OrbPaletteName[]; + +/** + * The colour for a Bot's seed: a 32-bit FNV-1a hash of it, modulo the nine. Stable across reloads + * and releases, so a Bot keeps its colour. + */ +export function orbPaletteFor(seed: string): OrbPaletteName { + let h = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return ORB_PALETTES[(h >>> 0) % ORB_PALETTES.length] as OrbPaletteName; +} + +/** 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) { + const rot = 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; a faster swirl joins them while it works. */ +function mesh( + ctx: Ctx, + R: number, + S: OrbPose, + pal: Palette, + 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).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).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); +} + +/** One ring that leaves the orb and fades: the landing's ping. */ +function ping(ctx: Ctx, r0: number, age: number, pal: Palette, dark: boolean) { + const dur = 1; + if (!(age >= 0 && age < dur)) return; + const u = age / dur; + 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(); +} + +/** 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, + dark: boolean, + size: number, +) { + const na = S.noticeAge; + const perk = na >= 0 && na < 1.6 ? 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, 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; + 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 in `palette`, 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, palette: name }: { dark: boolean; palette: OrbPaletteName }, +) { + const finish = finishPhase(S.finishAge); + const pal = palette( + name, + 0.9 + 0.14 * clamp(S.listen) + 0.08 * S.think + 0.12 * clamp(S.done), + -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, dark, size * 0.72); +} diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx new file mode 100644 index 0000000000..eda56ee4ac --- /dev/null +++ b/app/tests/bot-orb.test.tsx @@ -0,0 +1,193 @@ +import { describe, expect, test } from "bun:test"; +import { + drawBotOrb, + FINISH_HELD, + FINISH_S, + finishPhase, + ORB_PALETTES, + type OrbPose, + oklch, + orbPaletteFor, + palette, + RAYS, + track, +} from "@/components/agents/orb/bot-orb-draw"; + +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, and counts the frames + * drawn, each of which starts by clearing the canvas. + */ +function fakeContext() { + const gradient = { addColorStop: () => {} }; + const ctx = { + strokes: 0, + draws: 0, + stroke() { + this.strokes++; + }, + clearRect() { + this.draws++; + }, + createLinearGradient: () => gradient, + createRadialGradient: () => 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; + }, + }); +} + +/** Every stroke one frame draws: the rim, the landing's ping, and a finish's rays and check. */ +const strokesOf = (pose: OrbPose) => { + const ctx = fakeContext(); + drawBotOrb(ctx as unknown as CanvasRenderingContext2D, 60, pose, { + dark: false, + palette: "violet", + }); + return ctx.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 palette", () => { + for (const seed of TEAM) { + expect(orbPaletteFor(seed)).toBe(orbPaletteFor(seed)); + expect(ORB_PALETTES).toContain(orbPaletteFor(seed)); + } + // Pinned, so a change to the hash, which would recolour every Bot, can't land unnoticed. + expect(orbPaletteFor("research-desk")).toBe("sky"); + expect(orbPaletteFor("release-notes")).toBe("violet"); + expect(ORB_PALETTES).toContain(orbPaletteFor("")); + }); + + 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); + expect(new Set(TEAM.map(orbPaletteFor)).size).toBeGreaterThan(4); + }); + + 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); + }); +}); + +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); + }); +}); From 867dd0ea178b051f1f11c9f09677f9ced4cceaf3 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 13:53:34 -0700 Subject: [PATCH 04/18] feat(orb): one animation clock for every orb, and eyes that follow the pointer --- app/src/components/agents/orb/bot-orb.tsx | 468 +++++++++++++++++++++ app/src/components/agents/orb/orb-clock.ts | 140 ++++++ app/tests/bot-orb.test.tsx | 387 ++++++++++++++++- 3 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 app/src/components/agents/orb/bot-orb.tsx create mode 100644 app/src/components/agents/orb/orb-clock.ts 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..44c37f1e08 --- /dev/null +++ b/app/src/components/agents/orb/bot-orb.tsx @@ -0,0 +1,468 @@ +import { useReducedMotion } from "motion/react"; +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { + blinkCurve, + clamp, + drawBotOrb, + FINISH_HELD, + FINISH_S, + mulberry32, + nodCurve, + type OrbPaletteName, + type OrbPose, + restLook, + 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. */ +const SMALL = 40; +/** 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, so a list of them doesn't fidget. + */ +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: 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, or null where + * there is no 2D context to draw with. + */ +function surface(canvas: HTMLCanvasElement, size: number) { + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + const box = size * (1 + 2 * SPILL); + const dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1); + canvas.width = Math.round(box * dpr); + canvas.height = Math.round(box * dpr); + return (pose: OrbPose, palette: OrbPaletteName) => { + 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: document.documentElement.classList.contains("dark"), + palette, + }); + }; +} + +let seeds = 0; +const secondsSince = (start: number) => (performance.now() - start) / 1000; + +/** + * A Bot's orb, in `palette`. `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. + * + * Every orb on the page draws on one shared animation frame, and only while it is on screen and the + * tab is shown. 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, + palette, + size, + state = "idle", + still = false, +}: { + className?: string; + palette: OrbPaletteName; + size: number; + state?: BotOrbState; + still?: boolean; +}) { + const boxRef = useRef(null); + const canvasRef = useRef(null); + const reduced = (useReducedMotion() ?? false) || still; + const clock = useRef<{ start: number; seed: number } | null>(null); + clock.current ??= { start: performance.now(), seed: ++seeds * 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) : null; + if (!draw || !clock.current) return; + const { seed } = clock.current; + const held = + state === "finished" + ? { ...settled(shown.current ?? "idle"), finishedAt: 0 } + : settled(state); + const paint = () => + draw( + poseAt(held, 1.6, seed, { reduced: true, small: size < SMALL }), + palette, + ); + paint(); + const theme = new MutationObserver(paint); + theme.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => theme.disconnect(); + }, [reduced, size, palette, 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. + useEffect(() => { + const canvas = canvasRef.current; + const box = boxRef.current; + const draw = !reduced && canvas ? surface(canvas, size) : null; + if (!draw || !box || !clock.current || !gaze.current) return; + const { start, seed } = clock.current; + const eyes = gaze.current; + const small = size < SMALL; + const measure = () => box.getBoundingClientRect(); + const frame = (now: number) => { + 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, seed, { + reduced: false, + small, + }); + draw(lookAt(pose, eyes), palette); + }; + 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, palette]); + + 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/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index eda56ee4ac..8297202c87 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -1,4 +1,19 @@ -import { describe, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, render } from "@testing-library/react"; +import { + BotOrb, + type BotOrbState, + followPointer, + restingGaze, +} from "@/components/agents/orb/bot-orb"; import { drawBotOrb, FINISH_HELD, @@ -12,6 +27,15 @@ import { RAYS, 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, @@ -191,3 +215,364 @@ describe("a finish", () => { 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: + * `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() { + const due = [...queue.values()]; + queue.clear(); + const now = performance.now(); + 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("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(); + } + }); + }); + + 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(); + } + }); +}); From 7a8f072e7eefdd6a12e29a815cda0e90be3dd403 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 13:56:05 -0700 Subject: [PATCH 05/18] feat(orb): every Bot and OpenBot's own mark draw the orb --- app/src/components/agents/abstract-avatar.tsx | 15 +- app/src/components/agents/orb/agent-orb.tsx | 458 ++---------------- app/src/components/agents/orb/ai-core.tsx | 395 --------------- app/tests/bot-orb.test.tsx | 51 ++ app/tests/channel-work-row.test.tsx | 2 +- 5 files changed, 96 insertions(+), 825 deletions(-) delete mode 100644 app/src/components/agents/orb/ai-core.tsx diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx index 9ca9845070..57df2578c9 100644 --- a/app/src/components/agents/abstract-avatar.tsx +++ b/app/src/components/agents/abstract-avatar.tsx @@ -1,5 +1,13 @@ -import Avatar from "boring-avatars"; +import { BotOrb } from "@/components/agents/orb/bot-orb"; +import { orbPaletteFor } from "@/components/agents/orb/bot-orb-draw"; +/** + * A Bot's face: its orb, in the colour its seed picks, so the same Bot looks the same everywhere. + * 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 +24,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..a5365af6b1 100644 --- a/app/src/components/agents/orb/agent-orb.tsx +++ b/app/src/components/agents/orb/agent-orb.tsx @@ -1,437 +1,47 @@ -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 { - /** - * Live audio level, 0–1. Pass the `MotionValue` from `useAudioAmplitude` so - * the signal never re-renders React. - */ - 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; - }; + /** Its footprint, in px, as "64px". */ size?: string; - /** Shared AI state driving speed, scale, saturation and reactivity. */ 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) { 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/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index 8297202c87..333c2d7573 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -8,6 +8,8 @@ import { } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; 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, @@ -576,3 +578,52 @@ describe("its eyes", () => { } }); }); + +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(orbPaletteFor("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; + }; + expect(paletteOf("research-desk")).not.toBe(paletteOf("release-notes")); + 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"); + }); +}); 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", () => { From a901367689f02c0fe67963662faf96364a7eaee3 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 14:02:37 -0700 Subject: [PATCH 06/18] feat(orb): conversation faces and onboarding's example Bots draw the orb The last two places still drawing boring-avatars' gradient circles now draw the orb in the colour the Bot's id picks, so a Bot looks the same in the sidebar, the conversation header and its profile. Nothing imports boring-avatars any more, so it leaves the app's dependencies. --- app/package.json | 1 - app/src/components/channels/avatar.tsx | 14 +++++++++----- app/src/routes/_authed/onboarding.tsx | 4 ++-- bun.lock | 3 --- 4 files changed, 11 insertions(+), 11 deletions(-) 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/channels/avatar.tsx b/app/src/components/channels/avatar.tsx index 44487201b5..2192e39759 100644 --- a/app/src/components/channels/avatar.tsx +++ b/app/src/components/channels/avatar.tsx @@ -1,13 +1,15 @@ -import Avatar from "boring-avatars"; import { memo } from "react"; +import { BotOrb } from "@/components/agents/orb/bot-orb"; +import { orbPaletteFor } from "@/components/agents/orb/bot-orb-draw"; import { cn } from "@/lib/utils"; /** * Memoized roster avatar. Row updates usually change preview/timestamp only, and * `use-channel-events` preserves participant id arrays for unchanged rows. * - * `size-full` opts the generated SVG out of ancestor icon selectors such as - * `[&_svg:not([class*='size-'])]:size-4`. + * Each face is the Bot's orb, in the colour its id picks: the same colour `AbstractAvatar` draws + * from the seed, which is the id unless a tenant package sets one. Clipped to its circle, so its glow + * never spills into the row. * * `typing` overlays a working indicator at the bottom-right β€” three bouncing dots, so a channel * whose agent is mid-turn reads as busy from the roster without moving the row's layout. @@ -23,7 +25,9 @@ export const ChannelAvatar = memo(function ChannelAvatar({ }) { const avatar = participantIds.length === 1 ? ( - + + + ) : ( ); @@ -97,7 +101,7 @@ function GroupFaces({ WebkitMaskComposite: "source-in", }} > - + ); })} 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/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=="], From 291277b306443eb430b54d366f853160beb4ef26 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 14:05:14 -0700 Subject: [PATCH 07/18] docs: changelog for OpenBot's new look --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27745c6f84..8a4e7f8da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,16 @@ 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 uses a new visual system: cool neutral greys, one purple accent for focus, + selection and links, four status colours, frosted chrome and new motion. 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, in a colour picked from the Bot, that blinks + and follows the pointer. It holds still with reduced motion. + ### 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 From 2f423ec9c9c04e135eaed4ff700ef35808e784b3 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 14:15:45 -0700 Subject: [PATCH 08/18] fix(orb): keep small faces in their circles, sharp on any screen, still when asked A Bot's orb is a block, so a group conversation's 12px faces no longer sit on the header's text baseline and drop half out of their circles. It resizes its canvas when the screen's pixel density changes, so dragging a window to another screen or zooming no longer leaves it soft, and it follows the reduced-motion setting live rather than only when it first draws. The changelog and the stylesheet now describe what this step draws, not what later steps will. --- CHANGELOG.md | 6 +- app/src/components/agents/abstract-avatar.tsx | 4 +- app/src/components/agents/orb/bot-orb.tsx | 71 ++++++++++++++++--- app/src/components/channels/avatar.tsx | 3 +- app/src/theme.css | 7 +- app/tests/bot-orb.test.tsx | 7 ++ 6 files changed, 79 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4e7f8da9..d48712a801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,9 +135,9 @@ changes for a deployment. ### OpenBot has a new look -- Every screen uses a new visual system: cool neutral greys, one purple accent for focus, - selection and links, four status colours, frosted chrome and new motion. Text now follows the - browser's own size, so it is a little larger than before. +- 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, in a colour picked from the Bot, that blinks diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx index 57df2578c9..a718780cca 100644 --- a/app/src/components/agents/abstract-avatar.tsx +++ b/app/src/components/agents/abstract-avatar.tsx @@ -2,7 +2,9 @@ import { BotOrb } from "@/components/agents/orb/bot-orb"; import { orbPaletteFor } from "@/components/agents/orb/bot-orb-draw"; /** - * A Bot's face: its orb, in the colour its seed picks, so the same Bot looks the same everywhere. + * A Bot's face: its orb, in the colour 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. diff --git a/app/src/components/agents/orb/bot-orb.tsx b/app/src/components/agents/orb/bot-orb.tsx index 44c37f1e08..c8d4ebcec3 100644 --- a/app/src/components/agents/orb/bot-orb.tsx +++ b/app/src/components/agents/orb/bot-orb.tsx @@ -1,5 +1,4 @@ -import { useReducedMotion } from "motion/react"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; import { cn } from "@/lib/utils"; import { blinkCurve, @@ -289,10 +288,16 @@ function surface(canvas: HTMLCanvasElement, size: number) { const ctx = canvas.getContext("2d"); if (!ctx) return null; const box = size * (1 + 2 * SPILL); - const dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1); - canvas.width = Math.round(box * dpr); - canvas.height = Math.round(box * dpr); + let dpr = 0; return (pose: OrbPose, palette: OrbPaletteName) => { + // 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); + 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); @@ -303,6 +308,49 @@ function surface(canvas: HTMLCanvasElement, size: number) { }; } +/** + * 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 seeds = 0; const secondsSince = (start: number) => (performance.now() - start) / 1000; @@ -333,7 +381,7 @@ export function BotOrb({ }) { const boxRef = useRef(null); const canvasRef = useRef(null); - const reduced = (useReducedMotion() ?? false) || still; + const reduced = usePrefersReducedMotion() || still; const clock = useRef<{ start: number; seed: number } | null>(null); clock.current ??= { start: performance.now(), seed: ++seeds * 7 }; const motion = useRef(null); @@ -388,7 +436,11 @@ export function BotOrb({ attributes: true, attributeFilter: ["class"], }); - return () => theme.disconnect(); + const unwatch = watchResolution(paint); + return () => { + theme.disconnect(); + unwatch(); + }; }, [reduced, size, palette, state]); // Alive: drawn on the shared clock while it is on screen. It counts as on screen until an @@ -444,10 +496,7 @@ export function BotOrb({ return ( ", () => { 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(); From cb8f677604caab3f8d6708c204aa8ce6ff3b2244 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 14:21:20 -0700 Subject: [PATCH 09/18] fix(orb): the skins draw the orb too The skins import the orb's state type from agent-orb now that ai-core is gone, OpenBot's mark keeps its agent-orb class for them, and their tests check the orb's colour rather than boring-avatars' SVG. The Appearance test's preferences carry the new skin field. --- app/src/components/agents/orb/agent-orb.tsx | 2 +- app/src/skins/openduet/header.tsx | 2 +- app/tests/settings-appearance.test.tsx | 1 + app/tests/skins-opendots-frame.test.tsx | 4 ++-- app/tests/skins-openduet-frame.test.tsx | 6 ++++-- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/components/agents/orb/agent-orb.tsx b/app/src/components/agents/orb/agent-orb.tsx index a5365af6b1..2594bbc527 100644 --- a/app/src/components/agents/orb/agent-orb.tsx +++ b/app/src/components/agents/orb/agent-orb.tsx @@ -38,7 +38,7 @@ export default function AgentOrb({ }: AgentOrbProps) { return ( { * 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. */ + /** Which colour one avatar's orb is drawn in, which is all its seed decides. */ const drawing = (root: Element | null) => - (root?.querySelector("svg")?.outerHTML ?? "").replace(/_r_[\w]+_/g, ""); + root?.querySelector("[data-palette]")?.getAttribute("data-palette") ?? ""; test("the frame names the product from brand.yaml and never says OpenDots or Dots", async () => { const { view } = draw("/channel/new?agent=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 () => { From f5df1e12c9ce369799866b40199dd41b9d66845f Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 14:42:02 -0700 Subject: [PATCH 10/18] fix(orb): OpenMuse's header draws its face at the size of its slot AgentOrb read "100%" as 100px, so with no Bot yet the header drew a 100px orb in a 44 or 52px slot. With a Bot, the classes that used to rescale boring-avatars' SVG squeezed the avatar's circle to 44px below 900px wide while the orb inside stayed 52px, clipped and off centre. The frame now picks 44 or 52px from the window's width and draws the face at that size, the selectors that matched only the old SVG are gone here and from OpenDots' Bot rows, and AgentOrb takes its size only in px and refuses anything else. --- app/src/components/agents/orb/agent-orb.tsx | 10 +++- app/src/skins/opendots/sidebar.tsx | 2 - app/src/skins/openmuse/frame.tsx | 34 ++++++++++-- app/tests/bot-orb.test.tsx | 10 ++++ app/tests/skins-openmuse.test.tsx | 60 +++++++++++++++++++++ 5 files changed, 108 insertions(+), 8 deletions(-) diff --git a/app/src/components/agents/orb/agent-orb.tsx b/app/src/components/agents/orb/agent-orb.tsx index 2594bbc527..30a42be29a 100644 --- a/app/src/components/agents/orb/agent-orb.tsx +++ b/app/src/components/agents/orb/agent-orb.tsx @@ -25,8 +25,11 @@ const ORB_STATE: Record = { export interface AgentOrbProps { className?: string; - /** Its footprint, in px, as "64px". */ - size?: string; + /** + * 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. + */ + size?: `${number}px`; state?: AIState; } @@ -36,6 +39,9 @@ export default function AgentOrb({ size = "192px", state = "idle", }: AgentOrbProps) { + if (!/^\d+(\.\d+)?px$/.test(size)) { + throw new Error(`AgentOrb's size is in px, as "64px", not "${size}".`); + } return ( { + 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/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index 34e38c9fed..66e054b483 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -633,4 +633,14 @@ describe("a Bot's face and OpenBot's mark", () => { 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/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", () => { From 851afa13f6a3cba250e7d620e9c20178b72c7f50 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 14:47:33 -0700 Subject: [PATCH 11/18] fix(orb): a face at rest draws nothing until it changes Every orb on screen redrew on every animation frame, uncapped, so a sidebar of conversations kept a few dozen canvases repainting at the screen's full rate while the app sat idle: a small orb's colour still drifted, so no two frames were ever the same. A small orb's colour now holds still, an orb skips any frame that would draw what its canvas already shows in the same theme and at the same density, and a small orb draws at most about thirty frames a second. A face at rest with the pointer still now draws only to blink. --- app/src/components/agents/orb/bot-orb-draw.ts | 36 ++++++- app/src/components/agents/orb/bot-orb.tsx | 64 +++++++---- app/tests/bot-orb.test.tsx | 102 +++++++++++++++++- 3 files changed, 171 insertions(+), 31 deletions(-) diff --git a/app/src/components/agents/orb/bot-orb-draw.ts b/app/src/components/agents/orb/bot-orb-draw.ts index d1605ae02c..741e718d76 100644 --- a/app/src/components/agents/orb/bot-orb-draw.ts +++ b/app/src/components/agents/orb/bot-orb-draw.ts @@ -491,11 +491,13 @@ function glow(ctx: Ctx, R: number, amt: number, c: Rgb, dark: boolean) { 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) { - const dur = 1; - if (!(age >= 0 && age < dur)) return; - const u = age / dur; + 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( @@ -606,6 +608,9 @@ function eyePath(ctx: Ctx, w: number, h: number, smile: number) { 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, @@ -616,7 +621,8 @@ function sphere( size: number, ) { const na = S.noticeAge; - const perk = na >= 0 && na < 1.6 ? Math.exp(-2.6 * na) * smooth(na / 0.1) : 0; + 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(); @@ -699,3 +705,25 @@ export function drawBotOrb( rays(ctx, r, finish.rays, pal, dark); sphere(ctx, r, S, pal, 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 index c8d4ebcec3..3e2b956379 100644 --- a/app/src/components/agents/orb/bot-orb.tsx +++ b/app/src/components/agents/orb/bot-orb.tsx @@ -11,6 +11,7 @@ import { type OrbPaletteName, type OrbPose, restLook, + samePicture, type Track, type TrackKey, track, @@ -52,8 +53,15 @@ const BLINK_EVERY = 4.2; 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. */ +/** 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, @@ -156,7 +164,8 @@ function enter(m: Motion, state: BotOrbState, now: number): Motion { /** * 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, so a list of them doesn't fidget. + * 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, @@ -178,7 +187,7 @@ function poseAt( } } return { - ta: t, + ta: small ? STILL_T : t, listen, think, done: Math.max(0, track(m.done, t)), @@ -281,18 +290,29 @@ function lookAt(pose: OrbPose, gaze: Gaze): OrbPose { } /** - * Sizes the canvas for a footprint of `size` and returns how to draw a pose on it, or null where - * there is no 2D context to draw with. + * Sizes the canvas for a footprint of `size` and returns how to draw a pose on it in `palette`, 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) { +function surface( + canvas: HTMLCanvasElement, + size: number, + palette: OrbPaletteName, +) { const ctx = canvas.getContext("2d"); if (!ctx) return null; const box = size * (1 + 2 * SPILL); let dpr = 0; - return (pose: OrbPose, palette: OrbPaletteName) => { + 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); @@ -301,10 +321,7 @@ function surface(canvas: HTMLCanvasElement, size: number) { 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: document.documentElement.classList.contains("dark"), - palette, - }); + drawBotOrb(ctx, size, pose, { dark, palette }); }; } @@ -360,11 +377,11 @@ const secondsSince = (start: number) => (performance.now() - start) / 1000; * 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. + * 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 and the - * tab is shown. 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. + * 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, @@ -418,7 +435,7 @@ export function BotOrb({ // 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) : null; + const draw = reduced && canvas ? surface(canvas, size, palette) : null; if (!draw || !clock.current) return; const { seed } = clock.current; const held = @@ -426,10 +443,7 @@ export function BotOrb({ ? { ...settled(shown.current ?? "idle"), finishedAt: 0 } : settled(state); const paint = () => - draw( - poseAt(held, 1.6, seed, { reduced: true, small: size < SMALL }), - palette, - ); + draw(poseAt(held, STILL_T, seed, { reduced: true, small: size < SMALL })); paint(); const theme = new MutationObserver(paint); theme.observe(document.documentElement, { @@ -445,17 +459,21 @@ export function BotOrb({ // 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. + // 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) : null; + const draw = !reduced && canvas ? surface(canvas, size, palette) : null; if (!draw || !box || !clock.current || !gaze.current) return; const { start, seed } = 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) { @@ -465,7 +483,7 @@ export function BotOrb({ reduced: false, small, }); - draw(lookAt(pose, eyes), palette); + draw(lookAt(pose, eyes)); }; let unsubscribe: (() => void) | null = null; const onScreen = (on: boolean) => { diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index 66e054b483..0f6bc3dbea 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -27,6 +27,7 @@ import { orbPaletteFor, palette, RAYS, + samePicture, track, } from "@/components/agents/orb/bot-orb-draw"; import { subscribePointer } from "@/components/agents/orb/orb-clock"; @@ -181,6 +182,18 @@ 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", () => { @@ -229,8 +242,9 @@ function withContext(ctx: ReturnType) { } /** - * Animation frames that run only when the test says, as the browser would at its next paint: - * `requested` counts every frame asked for, `pending` the ones not yet run or cancelled. + * 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(); @@ -253,10 +267,9 @@ function manualFrames() { get pending() { return queue.size; }, - run() { + run(now = performance.now()) { const due = [...queue.values()]; queue.clear(); - const now = performance.now(); for (const callback of due) callback(now); }, restore() { @@ -423,6 +436,87 @@ describe("", () => { }); }); + 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(); From cfd681dfc017f9e794da385fc7e64630437b6e6f Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 14:55:14 -0700 Subject: [PATCH 12/18] fix(orb): two Bots in one colour no longer share a face, and none is violet A Bot's face was one of nine colours and nothing else, so a team of four had about an even chance of two identical faces. In the Bots OpenBot ships with, three were the same sky blue, and release notes wore violet, OpenBot's own mark. A Bot is now drawn from its seed: one of the eight colours that aren't violet, with its colour masses turned, its hue nudged and its eyes spaced by the same hash, so two Bots in one colour still look apart. BotOrb takes the seed rather than a colour and draws OpenBot's violet when it has none. The OpenDots avatar test checked one colour in nine, so it passed or failed on the hash; it now checks that the seed reaches the orb. --- CHANGELOG.md | 6 +- app/src/components/agents/abstract-avatar.tsx | 5 +- app/src/components/agents/orb/agent-orb.tsx | 1 - app/src/components/agents/orb/bot-orb-draw.ts | 95 +++++++++++--- app/src/components/agents/orb/bot-orb.tsx | 65 +++++----- app/src/components/channels/avatar.tsx | 9 +- app/tests/bot-orb.test.tsx | 122 +++++++++++++----- app/tests/skins-opendots-frame.test.tsx | 26 ++-- 8 files changed, 218 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d48712a801..ef2661bd6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,8 +140,10 @@ changes for a deployment. 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, in a colour picked from the Bot, that blinks - and follows the pointer. It holds still with reduced motion. +- 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 diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx index a718780cca..d746511206 100644 --- a/app/src/components/agents/abstract-avatar.tsx +++ b/app/src/components/agents/abstract-avatar.tsx @@ -1,8 +1,7 @@ import { BotOrb } from "@/components/agents/orb/bot-orb"; -import { orbPaletteFor } from "@/components/agents/orb/bot-orb-draw"; /** - * A Bot's face: its orb, in the colour its seed picks. The seed is the Bot's id unless a tenant + * 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. @@ -26,7 +25,7 @@ export function AbstractAvatar({ className="inline-flex shrink-0 overflow-hidden rounded-full" style={{ height: size, width: size }} > - + ); } diff --git a/app/src/components/agents/orb/agent-orb.tsx b/app/src/components/agents/orb/agent-orb.tsx index 30a42be29a..4f953f922b 100644 --- a/app/src/components/agents/orb/agent-orb.tsx +++ b/app/src/components/agents/orb/agent-orb.tsx @@ -45,7 +45,6 @@ export default function AgentOrb({ return ( diff --git a/app/src/components/agents/orb/bot-orb-draw.ts b/app/src/components/agents/orb/bot-orb-draw.ts index 741e718d76..f3e3f2b370 100644 --- a/app/src/components/agents/orb/bot-orb-draw.ts +++ b/app/src/components/agents/orb/bot-orb-draw.ts @@ -1,7 +1,8 @@ /** * 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 - * is picked from its seed, and OpenBot's own is violet. It also plays a finish: the check and the + * drifting colour with two eye-lights that look, blink and smile. It comes in nine colours. A Bot's + * look, its colour and the few small ways two Bots in one colour still differ, is picked from its + * seed; OpenBot's own is violet, which no Bot is given. It also plays a finish: the check and the * burst of rays the orb shows when something the person allowed is done. * * Every frame is a pure function of an `OrbPose`, so the component that animates it owns time and @@ -174,8 +175,8 @@ type PaletteSpec = { }; /** - * The nine colours. A Bot's is `orbPaletteFor` its seed, which picks by position in `ORB_PALETTES`: - * add a colour at the end, never reorder them, or every Bot changes colour. + * The nine colours: violet, OpenBot's own, and the eight a Bot's look is picked from by position. + * Adding, removing or reordering one changes the colour of most Bots. */ const PALETTES = { violet: { @@ -256,20 +257,67 @@ const PALETTES = { export type OrbPaletteName = keyof typeof PALETTES; -/** Every colour, in the order `orbPaletteFor` picks from. */ +/** Every colour, OpenBot's violet first. */ export const ORB_PALETTES = Object.keys(PALETTES) as readonly OrbPaletteName[]; +/** The eight colours a Bot can be: all but violet, so a Bot is never mistaken for OpenBot. */ +const BOT_PALETTES = ORB_PALETTES.filter((name) => name !== "violet"); + /** - * The colour for a Bot's seed: a 32-bit FNV-1a hash of it, modulo the nine. Stable across reloads - * and releases, so a Bot keeps its colour. + * 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 function orbPaletteFor(seed: string): OrbPaletteName { +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); } - return ORB_PALETTES[(h >>> 0) % ORB_PALETTES.length] as OrbPaletteName; + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b); + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35); + h ^= h >>> 16; + return h >>> 0; +} + +/** + * The look for a Bot's seed. Eight colours repeat soon in a team, so the same hash also turns the + * colour masses, nudges the hue and spaces the eyes, and two Bots in one colour still look apart. + * Stable across reloads and releases, so a Bot keeps its face. + */ +export function orbLookFor(seed: string): OrbLook { + const h = hashSeed(seed); + // The eight colours take the low three bits, and each of the rest the next eight. + const share = (from: number) => ((h >>> from) & 0xff) / 256; + return { + palette: BOT_PALETTES[h % BOT_PALETTES.length] as OrbPaletteName, + turn: share(3) * TAU, + hue: Math.round((share(11) * 2 - 1) * HUE_NUDGE), + eyes: 1 + (share(19) * 2 - 1) * EYE_NUDGE, + }; } /** A colour's tones as sRGB, ready to draw. */ @@ -412,8 +460,8 @@ const MESH = [ [2.36, 0.45, 0.88], ] as const; -function meshPos(ta: number, fast: boolean) { - const rot = fast ? 1.45 * ta + 1.1 : 0.22 * ta; +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] + @@ -425,24 +473,28 @@ function meshPos(ta: number, fast: boolean) { }); } -/** Four soft colour masses drifting round a pale base; a faster swirl joins them while it works. */ +/** + * 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).forEach((p, i) => { + 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).forEach((p, i) => { + meshPos(S.ta, true, turn).forEach((p, i) => { blob( ctx, p[0] * R + lx * R * 0.6, @@ -617,6 +669,7 @@ function sphere( R: number, S: OrbPose, pal: Palette, + look: OrbLook, dark: boolean, size: number, ) { @@ -633,7 +686,7 @@ function sphere( 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, Math.sin(yaw) * 0.2, -Math.sin(pitch) * 0.16); + 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); @@ -646,7 +699,7 @@ function sphere( 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; + 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); @@ -675,7 +728,7 @@ function sphere( } /** - * The whole orb in `palette`, centred on the context's origin, for a footprint of `size` CSS px. + * 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. */ @@ -683,13 +736,13 @@ export function drawBotOrb( ctx: Ctx, size: number, S: OrbPose, - { dark, palette: name }: { dark: boolean; palette: OrbPaletteName }, + { dark, look }: { dark: boolean; look: OrbLook }, ) { const finish = finishPhase(S.finishAge); const pal = palette( - name, + look.palette, 0.9 + 0.14 * clamp(S.listen) + 0.08 * S.think + 0.12 * clamp(S.done), - -6 * S.think, + look.hue - 6 * S.think, ); const r = (size / 2) * 0.72; const sc = scaleOf(S, finish); @@ -703,7 +756,7 @@ export function drawBotOrb( 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, dark, size * 0.72); + 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. */ diff --git a/app/src/components/agents/orb/bot-orb.tsx b/app/src/components/agents/orb/bot-orb.tsx index 3e2b956379..d628b9cd45 100644 --- a/app/src/components/agents/orb/bot-orb.tsx +++ b/app/src/components/agents/orb/bot-orb.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { cn } from "@/lib/utils"; import { blinkCurve, @@ -8,8 +8,10 @@ import { FINISH_S, mulberry32, nodCurve, - type OrbPaletteName, + OPENBOT_LOOK, + type OrbLook, type OrbPose, + orbLookFor, restLook, samePicture, type Track, @@ -290,15 +292,11 @@ function lookAt(pose: OrbPose, gaze: Gaze): OrbPose { } /** - * Sizes the canvas for a footprint of `size` and returns how to draw a pose on it in `palette`, or + * 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, - palette: OrbPaletteName, -) { +function surface(canvas: HTMLCanvasElement, size: number, look: OrbLook) { const ctx = canvas.getContext("2d"); if (!ctx) return null; const box = size * (1 + 2 * SPILL); @@ -321,7 +319,7 @@ function surface( 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, palette }); + drawBotOrb(ctx, size, pose, { dark, look }); }; } @@ -368,16 +366,17 @@ function usePrefersReducedMotion(): boolean { ); } -let seeds = 0; +let staggers = 0; const secondsSince = (start: number) => (performance.now() - start) / 1000; /** - * A Bot's orb, in `palette`. `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. + * 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 @@ -385,13 +384,13 @@ const secondsSince = (start: number) => (performance.now() - start) / 1000; */ export function BotOrb({ className, - palette, + seed, size, state = "idle", still = false, }: { className?: string; - palette: OrbPaletteName; + seed?: string; size: number; state?: BotOrbState; still?: boolean; @@ -399,8 +398,13 @@ export function BotOrb({ const boxRef = useRef(null); const canvasRef = useRef(null); const reduced = usePrefersReducedMotion() || still; - const clock = useRef<{ start: number; seed: number } | null>(null); - clock.current ??= { start: performance.now(), seed: ++seeds * 7 }; + 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); @@ -435,15 +439,17 @@ export function BotOrb({ // 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, palette) : null; + const draw = reduced && canvas ? surface(canvas, size, look) : null; if (!draw || !clock.current) return; - const { seed } = clock.current; + const { stagger } = clock.current; const held = state === "finished" ? { ...settled(shown.current ?? "idle"), finishedAt: 0 } : settled(state); const paint = () => - draw(poseAt(held, STILL_T, seed, { reduced: true, small: size < SMALL })); + draw( + poseAt(held, STILL_T, stagger, { reduced: true, small: size < SMALL }), + ); paint(); const theme = new MutationObserver(paint); theme.observe(document.documentElement, { @@ -455,7 +461,7 @@ export function BotOrb({ theme.disconnect(); unwatch(); }; - }, [reduced, size, palette, state]); + }, [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 @@ -464,9 +470,9 @@ export function BotOrb({ useEffect(() => { const canvas = canvasRef.current; const box = boxRef.current; - const draw = !reduced && canvas ? surface(canvas, size, palette) : null; + const draw = !reduced && canvas ? surface(canvas, size, look) : null; if (!draw || !box || !clock.current || !gaze.current) return; - const { start, seed } = clock.current; + const { start, stagger } = clock.current; const eyes = gaze.current; const small = size < SMALL; const measure = () => box.getBoundingClientRect(); @@ -479,7 +485,7 @@ export function BotOrb({ if (followPointer(eyes, t, state, measure) && motion.current) { motion.current = { ...motion.current, noticedAt: t }; } - const pose = poseAt(motion.current ?? settled(state), t, seed, { + const pose = poseAt(motion.current ?? settled(state), t, stagger, { reduced: false, small, }); @@ -508,14 +514,15 @@ export function BotOrb({ onScreen(false); unwatch(); }; - }, [reduced, size, palette]); + }, [reduced, size, look]); const spill = size * SPILL; return ( - + ) : ( @@ -102,7 +101,7 @@ function GroupFaces({ WebkitMaskComposite: "source-in", }} > - +

); })} diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index 0f6bc3dbea..2ca0690592 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -21,10 +21,12 @@ import { FINISH_HELD, FINISH_S, finishPhase, + OPENBOT_LOOK, ORB_PALETTES, + type OrbLook, type OrbPose, oklch, - orbPaletteFor, + orbLookFor, palette, RAYS, samePicture, @@ -58,22 +60,33 @@ const REST: OrbPose = { }; /** - * Just enough of a 2D context to run the drawing: it counts every stroke, and counts the frames - * drawn, each of which starts by clearing the canvas. + * 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 gradient = { addColorStop: () => {} }; + 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: () => gradient, + createRadialGradient(x: number, y: number) { + marks.push(`blob ${x.toFixed(2)},${y.toFixed(2)}`); + return gradient; + }, }; return new Proxy(ctx, { get: (target, key) => @@ -85,16 +98,19 @@ function fakeContext() { }); } -/** Every stroke one frame draws: the rim, the landing's ping, and a finish's rays and check. */ -const strokesOf = (pose: OrbPose) => { +/** 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, - palette: "violet", + look, }); - return ctx.strokes; + 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", @@ -155,21 +171,57 @@ describe("the colours", () => { expect(b).toBeGreaterThan(g); }); - test("a seed always gets the same palette", () => { + test("a seed always gets the same look", () => { for (const seed of TEAM) { - expect(orbPaletteFor(seed)).toBe(orbPaletteFor(seed)); - expect(ORB_PALETTES).toContain(orbPaletteFor(seed)); + expect(orbLookFor(seed)).toEqual(orbLookFor(seed)); + expect(ORB_PALETTES).toContain(orbLookFor(seed).palette); } - // Pinned, so a change to the hash, which would recolour every Bot, can't land unnoticed. - expect(orbPaletteFor("research-desk")).toBe("sky"); - expect(orbPaletteFor("release-notes")).toBe("violet"); - expect(ORB_PALETTES).toContain(orbPaletteFor("")); + // 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); - expect(new Set(TEAM.map(orbPaletteFor)).size).toBeGreaterThan(4); + 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("each palette is a colour of its own", () => { @@ -340,9 +392,7 @@ const orbOf = (container: HTMLElement) => describe("", () => { test("holds its footprint, hides from assistive tech, and draws on a canvas twice its size", () => { - const view = render( - , - ); + const view = render(); const orb = view.container.firstElementChild as HTMLElement; expect(orb.getAttribute("aria-hidden")).toBe("true"); expect(orb.dataset.state).toBe("listening"); @@ -354,7 +404,7 @@ describe("", () => { }); 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 view = render(); const orb = view.container.firstElementChild as HTMLElement; expect(orb.classList.contains("block")).toBe(true); expect(orb.classList.contains("inline-block")).toBe(false); @@ -365,7 +415,7 @@ describe("", () => { const frames = manualFrames(); const restore = withContext(ctx); try { - render(); + render(); expect(ctx.draws).toBe(1); document.documentElement.classList.add("dark"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -373,7 +423,7 @@ describe("", () => { expect(frames.requested).toBe(0); cleanup(); - render(); + render(); expect(frames.requested).toBe(1); } finally { cleanup(); @@ -388,7 +438,9 @@ describe("", () => { const ctx = fakeContext(); const restore = withContext(ctx); try { - render(); + render( + , + ); cleanup(); return ctx.strokes; } finally { @@ -399,11 +451,11 @@ describe("", () => { }); test("follows a change of state", () => { - const view = render(); + const view = render(); expect(orbOf(view.container).dataset.state).toBe("idle"); - view.rerender(); + view.rerender(); expect(orbOf(view.container).dataset.state).toBe("thinking"); - view.rerender(); + view.rerender(); expect(orbOf(view.container).dataset.state).toBe("finished"); }); @@ -416,7 +468,7 @@ describe("", () => { render(
{Array.from({ length: 50 }, (_, i) => `bot-${i}`).map((id) => ( - + ))}
, ); @@ -450,7 +502,7 @@ describe("", () => { const ctx = fakeContext(); const frames = manualFrames(); const restore = withContext(ctx); - const view = render(); + const view = render(); orbOf(view.container).getBoundingClientRect = () => ({ left: 100, top: 100, width: size, height: size }) as DOMRect; const start = performance.now(); @@ -523,7 +575,7 @@ describe("", () => { const frames = manualFrames(); const restore = withContext(ctx); try { - const view = render(); + const view = render(); const orb = orbOf(view.container); Sight.show(orb, false); expect(frames.pending).toBe(0); @@ -555,7 +607,7 @@ describe("", () => { document.dispatchEvent(new Event("visibilitychange")); }; try { - render(); + render(); expect(frames.pending).toBe(1); hide(true); expect(frames.pending).toBe(0); @@ -579,7 +631,7 @@ describe("", () => { const frames = manualFrames(); try { const view = render( - , + , ); expect(view.container.querySelector("canvas")).not.toBeNull(); // Nothing to draw on, so it asks for no frames. @@ -690,7 +742,8 @@ describe("a Bot's face and OpenBot's mark", () => { expect(avatar.style.width).toBe("36px"); const orb = orbOf(avatar); expect(orb.getAttribute("aria-hidden")).toBe("true"); - expect(orb.dataset.palette).toBe(orbPaletteFor("research-desk")); + 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(); @@ -703,7 +756,8 @@ describe("a Bot's face and OpenBot's mark", () => { cleanup(); return palette; }; - expect(paletteOf("research-desk")).not.toBe(paletteOf("release-notes")); + // 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")); }); diff --git a/app/tests/skins-opendots-frame.test.tsx b/app/tests/skins-opendots-frame.test.tsx index 9d6a2de277..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", () => { - /** Which colour one avatar's orb is drawn in, which is all its seed decides. */ - const drawing = (root: Element | null) => - root?.querySelector("[data-palette]")?.getAttribute("data-palette") ?? ""; + /* + * 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"); }); }); From c962106e0f3c2186cf70d0ae1a8d9c91ca178e4e Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Fri, 30 Oct 2026 08:56:05 -0700 Subject: [PATCH 13/18] feat(orb): a Bot's seed can name its colour A seed that starts with one of the eight Bot colours and a colon, as moss:k2p9x7aq, is drawn in that colour; the pattern's turn, the hue nudge and the eyes still come from the hash of the whole seed. Any other seed, violet: and unknown colours included, draws exactly as before, and the default team's looks are pinned so that stays true. The colours, the parse, the spelling a seed may have and the builders for a new look live in shared/bot-look.ts, so the server checks a look by the same rules the orb draws it by. --- app/src/components/agents/orb/bot-orb-draw.ts | 26 +++-- app/tests/bot-orb.test.tsx | 101 ++++++++++++++++++ shared/bot-look.test.ts | 94 ++++++++++++++++ shared/bot-look.ts | 93 ++++++++++++++++ 4 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 shared/bot-look.test.ts create mode 100644 shared/bot-look.ts diff --git a/app/src/components/agents/orb/bot-orb-draw.ts b/app/src/components/agents/orb/bot-orb-draw.ts index f3e3f2b370..cd1dff7f67 100644 --- a/app/src/components/agents/orb/bot-orb-draw.ts +++ b/app/src/components/agents/orb/bot-orb-draw.ts @@ -1,9 +1,16 @@ +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; 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. + * 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. @@ -175,8 +182,8 @@ type PaletteSpec = { }; /** - * The nine colours: violet, OpenBot's own, and the eight a Bot's look is picked from by position. - * Adding, removing or reordering one changes the colour of most Bots. + * 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: { @@ -253,16 +260,13 @@ const PALETTES = { base: [0.93, 0.012, 258], shade: [30, 38, 54], }, -} as const satisfies Record; +} 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[]; -/** The eight colours a Bot can be: all but violet, so a Bot is never mistaken for OpenBot. */ -const BOT_PALETTES = ORB_PALETTES.filter((name) => name !== "violet"); - /** * How one orb is drawn apart from its motion: its colour, how far round its colour masses are * turned, in radians, a nudge to its hue in degrees, and how far apart its eyes are, as a share of @@ -306,14 +310,16 @@ function hashSeed(seed: string): number { /** * The look for a Bot's seed. Eight colours repeat soon in a team, so the same hash also turns the * colour masses, nudges the hue and spaces the eyes, and two Bots in one colour still look apart. - * Stable across reloads and releases, so a Bot keeps its face. + * 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: BOT_PALETTES[h % BOT_PALETTES.length] as OrbPaletteName, + palette: + namedColour(seed) ?? (BOT_COLOURS[h % BOT_COLOURS.length] as BotColour), turn: share(3) * TAU, hue: Math.round((share(11) * 2 - 1) * HUE_NUDGE), eyes: 1 + (share(19) * 2 - 1) * EYE_NUDGE, diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index 2ca0690592..610c4ffb70 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -7,6 +7,7 @@ import { 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"; @@ -224,6 +225,106 @@ describe("the colours", () => { 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); 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", + "