From 68f9cd362b3df446f646bf6cc0d9c79080e67ef6 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Mon, 26 Oct 2026 16:41:41 -0700 Subject: [PATCH 1/2] Centre onboarding's pictures on a desktop, and draw every Bot as a Duet orb Two things wrong at desktop width in onboarding. Bring the agents you already have showed the brief demo left of centre over a frame of frameworks right of it. IllustrationFrame caps itself at max-w-lg and was centred only by the column around it; the capability demo wraps it in a full-width box, where it sat at the left edge. It now centres itself. Pick a Bot drew OpenDuet as its orb and every other Bot as a pink gradient disc, which read as another product. The orb now has the four colours the launch film's title uses: Ocean, OpenDuet's own, plus Ember, Lime and Graphite. Every other Bot wears one of those three, picked from its id so it is the same colour in the roster, its profile and the conversation list. --- .../src/components/agents/abstract-avatar.tsx | 39 +++++---- .../app/src/components/channels/avatar.tsx | 15 ++-- .../app/src/components/duet/mic-orb-draw.ts | 85 ++++++++++++++----- openbot/app/src/components/duet/mic-orb.tsx | 12 ++- .../onboarding/illustration-frame.tsx | 7 +- openbot/app/tests/tenant-bot-avatar.test.tsx | 39 +++++++-- 6 files changed, 142 insertions(+), 55 deletions(-) diff --git a/openbot/app/src/components/agents/abstract-avatar.tsx b/openbot/app/src/components/agents/abstract-avatar.tsx index fe3db546..760b4c1d 100644 --- a/openbot/app/src/components/agents/abstract-avatar.tsx +++ b/openbot/app/src/components/agents/abstract-avatar.tsx @@ -1,22 +1,36 @@ -import Avatar from "boring-avatars"; import { MicOrb } from "@/components/duet/mic-orb"; +import type { OrbColour } from "@/components/duet/mic-orb-draw"; import { isTenantBot } from "@/lib/agents/primary-bot"; -import { cn } from "@/lib/utils"; + +/** The orb's colours another Bot can wear: every one but Ocean, which is the tenant's own Bot's. */ +const OTHER_COLOURS: readonly OrbColour[] = ["ember", "lime", "graphite"]; + +/** The same colour for the same Bot, every time and everywhere it is drawn. */ +export function orbColourFor(id: string): OrbColour { + let hash = 0; + for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + return OTHER_COLOURS[hash % OTHER_COLOURS.length] ?? "ember"; +} /** - * A Bot's face. The tenant's own Bot is the mic orb, still, its glow left unclipped; every other Bot - * is the abstract disc drawn from its seed. + * A Bot's face: the mic orb, still, its glow left unclipped. The tenant's own Bot wears Ocean; every + * other Bot wears one of the orb's other colours, picked from its id, so a roster reads as one + * family of Duets. It used to be an abstract gradient disc, which on the onboarding roster sat beside + * OpenDuet's orb as something from another product. */ export function AbstractAvatar({ id, name, - seed, size = 40, }: { - /** The Bot's id, which decides whether this is the tenant's own Bot. */ + /** The Bot's id: whether this is the tenant's own Bot, and which colour any other Bot wears. */ id: string; name: string; - seed: string; + /** + * Kept for callers. The colour follows `id` instead, so a Bot is the same colour here as in the + * conversation list (`ChannelAvatar`), which only has its id. + */ + seed?: string; size?: number; }) { const own = isTenantBot(id); @@ -24,19 +38,12 @@ export function AbstractAvatar({ {/* The drawing carries its own role; hidden so the coworker is announced once, by name. */} ); diff --git a/openbot/app/src/components/channels/avatar.tsx b/openbot/app/src/components/channels/avatar.tsx index ae66ea0b..4b419e93 100644 --- a/openbot/app/src/components/channels/avatar.tsx +++ b/openbot/app/src/components/channels/avatar.tsx @@ -1,5 +1,5 @@ -import Avatar from "boring-avatars"; import { memo } from "react"; +import { orbColourFor } from "@/components/agents/abstract-avatar"; import { MicOrb } from "@/components/duet/mic-orb"; import { isTenantBot } from "@/lib/agents/primary-bot"; import { cn } from "@/lib/utils"; @@ -14,7 +14,8 @@ import { cn } from "@/lib/utils"; * `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. * - * The tenant's own Bot is the mic orb, still, as `AbstractAvatar` draws it. + * Each Bot is the mic orb, still, in the colour `AbstractAvatar` gives it: Ocean for the tenant's + * own Bot, another of the orb's colours for every other Bot, picked from its id. */ export const ChannelAvatar = memo(function ChannelAvatar({ participantIds, @@ -110,10 +111,12 @@ function GroupFaces({ } function Face({ id, size }: { id: string; size: number }) { - return isTenantBot(id) ? ( - - ) : ( - + return ( + ); } diff --git a/openbot/app/src/components/duet/mic-orb-draw.ts b/openbot/app/src/components/duet/mic-orb-draw.ts index aa8fbc29..df448ac4 100644 --- a/openbot/app/src/components/duet/mic-orb-draw.ts +++ b/openbot/app/src/components/duet/mic-orb-draw.ts @@ -160,37 +160,77 @@ const WHITE: Rgb = [255, 255, 255]; const INK: Rgb = [20, 16, 33]; /** - * Ocean, as oklch [L, C, h]: four tones for the sphere's drifting colour and its pale base. `violet` - * is also the deep tone the grilles are made of, and `mag` the light their slots glow with. + * The orb's colours, as oklch [L, C, h]: four tones for the sphere's drifting colour and its pale + * base. `violet` is also the deep tone the grilles are made of, and `mag` the light their slots glow + * with; `shade` tints the sphere's rim, as an rgb triple. + * + * Ocean is OpenDuet's own. The other three are the orb's other colours, the same four the launch + * film's title draws (launch-film/film/src/duet/micorb.ts), and are what every other Bot wears, so a + * roster reads as one family of Duets rather than OpenDuet beside unrelated discs. */ -const OCEAN = { - pink: [0.75, 0.13, 232], - blue: [0.74, 0.13, 205], - violet: [0.53, 0.2, 262], - mag: [0.86, 0.08, 220], - bg: [0.93, 0.025, 235], +const PALETTES = { + ocean: { + pink: [0.75, 0.13, 232], + blue: [0.74, 0.13, 205], + violet: [0.53, 0.2, 262], + mag: [0.86, 0.08, 220], + bg: [0.93, 0.025, 235], + shade: "14,30,80", + }, + ember: { + pink: [0.66, 0.19, 15], + blue: [0.8, 0.15, 72], + violet: [0.6, 0.19, 38], + mag: [0.88, 0.12, 90], + bg: [0.95, 0.03, 70], + shade: "92,34,20", + }, + lime: { + pink: [0.88, 0.19, 125], + blue: [0.74, 0.17, 145], + violet: [0.52, 0.14, 150], + mag: [0.93, 0.15, 108], + bg: [0.96, 0.04, 122], + shade: "22,62,36", + }, + graphite: { + pink: [0.84, 0.015, 260], + blue: [0.64, 0.2, 256], + violet: [0.4, 0.03, 265], + mag: [0.82, 0.1, 250], + bg: [0.86, 0.01, 260], + shade: "15,18,30", + }, } as const; -/** The tint of the sphere's shaded rim, as an rgb triple. */ -const SHADE = "14,30,80"; -type Palette = Record; +/** One of the orb's colours. Ocean is OpenDuet's own. */ +export type OrbColour = keyof typeof PALETTES; +type Tone = "pink" | "blue" | "violet" | "mag" | "bg"; + +type Palette = Record & { shade: string }; const paletteMemo = new Map(); -/** Ocean with its saturation and hue nudged, as the orb livens when it listens and cools when it works. */ -export function palette(sat: number, hue: number): Palette { +/** A colour with its saturation and hue nudged, as the orb livens when it listens and cools when it works. */ +export function palette( + sat: number, + hue: number, + colour: OrbColour = "ocean", +): Palette { const ks = Math.round(clamp(sat, 0.5, 1.3) * 50) / 50; const kh = Math.round(hue * 2) / 2; - const key = `${ks}|${kh}`; + const key = `${colour}|${ks}|${kh}`; let p = paletteMemo.get(key); if (!p) { - const c = (k: keyof typeof OCEAN, sk = ks, hk = kh) => - oklch(OCEAN[k][0], OCEAN[k][1] * sk, OCEAN[k][2] + hk); + const set = PALETTES[colour]; + const c = (k: Tone, sk = ks, hk = kh) => + oklch(set[k][0], set[k][1] * sk, set[k][2] + hk); p = { pink: c("pink"), blue: c("blue"), violet: c("violet"), mag: c("mag"), bg: c("bg", 1, kh * 0.5), + shade: set.shade, }; paletteMemo.set(key, p); } @@ -353,7 +393,7 @@ function mesh( } /** Lit top edge, shaded bottom: what makes the disc read as a sphere. Call inside the clip. */ -function rim(ctx: Ctx, R: number, dark: boolean) { +function rim(ctx: Ctx, R: number, dark: boolean, shade: string) { const g = ctx.createRadialGradient( 0, -R * 0.3, @@ -362,9 +402,9 @@ function rim(ctx: Ctx, R: number, dark: boolean) { -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})`); + 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); @@ -527,7 +567,7 @@ function sphere( 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, dark); + rim(ctx, R, dark, pal.shade); const small = size < 40; const widen = 1 + 0.16 * perk; const open = clamp(1 - 0.94 * S.blink) * (1 - 0.3 * S.think) * widen; @@ -669,12 +709,13 @@ export function drawMicOrb( ctx: Ctx, size: number, S: OrbPose, - { dark }: { dark: boolean }, + { dark, colour = "ocean" }: { dark: boolean; colour?: OrbColour }, ) { const finish = finishPhase(S.finishAge); const pal = palette( 0.9 + 0.14 * clamp(S.listen) + 0.08 * S.think + 0.12 * clamp(S.done), -6 * S.think, + colour, ); const r = (size / 2) * 0.72; const sc = scaleOf(S, finish); diff --git a/openbot/app/src/components/duet/mic-orb.tsx b/openbot/app/src/components/duet/mic-orb.tsx index 3a50567d..27bfa735 100644 --- a/openbot/app/src/components/duet/mic-orb.tsx +++ b/openbot/app/src/components/duet/mic-orb.tsx @@ -9,6 +9,7 @@ import { FINISH_S, mulberry32, nodCurve, + type OrbColour, type OrbPose, restLook, type Track, @@ -198,12 +199,15 @@ const secondsSince = (start: number) => (performance.now() - start) / 1000; */ export function MicOrb({ className, + colour = "ocean", keystrokes, size, state = "idle", still = false, }: { className?: string; + /** Ocean is OpenDuet's own; another Bot wears one of the others (`AbstractAvatar`). */ + colour?: OrbColour; keystrokes?: number; size: number; state?: MicOrbState; @@ -263,7 +267,10 @@ export function MicOrb({ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, box, box); ctx.translate(box / 2, box / 2); - drawMicOrb(ctx, size, pose, { dark: canvas.closest(".dark") !== null }); + drawMicOrb(ctx, size, pose, { + dark: canvas.closest(".dark") !== null, + colour, + }); }; if (reduced) { // A composed still in the state's pose: eyes open, its look held, no ping; finished, the check @@ -294,7 +301,7 @@ export function MicOrb({ frame = requestAnimationFrame(loop); }); return () => cancelAnimationFrame(frame); - }, [size, reduced, state]); + }, [size, reduced, state, colour]); const spill = size * SPILL; return ( @@ -304,6 +311,7 @@ export function MicOrb({ "pointer-events-none relative inline-block shrink-0", className, )} + data-colour={colour} data-state={state} style={{ width: size, height: size }} > diff --git a/openbot/app/src/components/onboarding/illustration-frame.tsx b/openbot/app/src/components/onboarding/illustration-frame.tsx index 42410fe6..e26bd409 100644 --- a/openbot/app/src/components/onboarding/illustration-frame.tsx +++ b/openbot/app/src/components/onboarding/illustration-frame.tsx @@ -14,6 +14,11 @@ import type * as React from "react"; * * The label sits on a wrapper rather than on the inert element because an inert element is removed * from the accessibility tree along with everything in it, its own name included. + * + * CENTRED BY ITSELF. Placed straight in a step's centred column it was centred by the column, but + * the capability demo wraps it in a full-width box, and there it sat at the box's left edge: on a + * desktop, "Bring the agents you already have" showed the brief left of centre over a frame of + * frameworks right of it. */ export function IllustrationFrame({ label, @@ -26,7 +31,7 @@ export function IllustrationFrame({ className?: string; }) { return ( -
+
({ discs: element.querySelectorAll("svg").length, }); +/** The orb colours drawn, in order: Ocean is the tenant's own Bot's, the rest another Bot's. */ +const colours = (element: Element) => + Array.from(element.querySelectorAll("[data-colour]"), (orb) => + orb.getAttribute("data-colour"), + ); + describe("", () => { test("draws the tenant's own Bot as the mic orb, its glow unclipped", () => { const view = render( @@ -116,13 +125,21 @@ describe("", () => { expect(avatar.style.width).toBe("28px"); }); - test("draws every other Bot as the disc from its seed, even one seeded like the tenant's", () => { + test("draws every other Bot as the orb in another of its colours, even one seeded like the tenant's", () => { const view = render( , ); const avatar = view.getByRole("img", { name: "Helper" }); - expect(drawings(avatar)).toEqual({ orbs: 0, discs: 1 }); - expect(avatar.className).toContain("rounded-full"); + expect(drawings(avatar)).toEqual({ orbs: 1, discs: 0 }); + expect(colours(avatar)).toEqual([orbColourFor("helper")]); + expect(colours(avatar)).not.toContain("ocean"); + }); + + test("gives a Bot the same colour every time, and never Ocean", () => { + for (const id of ["helper", "support-desk", "your-agent", "a", ""]) { + expect(orbColourFor(id)).toBe(orbColourFor(id)); + expect(["ember", "lime", "graphite"]).toContain(orbColourFor(id)); + } }); }); @@ -132,15 +149,21 @@ describe("", () => { expect(drawings(view.container)).toEqual({ orbs: 1, discs: 0 }); }); - test("keeps the disc for a conversation with another Bot", () => { + test("draws a conversation with another Bot as the orb in that Bot's colour", () => { const view = render(); - expect(drawings(view.container)).toEqual({ orbs: 0, discs: 1 }); + expect(drawings(view.container)).toEqual({ orbs: 1, discs: 0 }); + expect(colours(view.container)).toEqual([orbColourFor("helper")]); }); - test("draws the tenant's own Bot as the orb among a group's faces", () => { + test("draws the tenant's own Bot in Ocean among a group's faces, the others in theirs", () => { const view = render( , ); - expect(drawings(view.container)).toEqual({ orbs: 1, discs: 2 }); + expect(drawings(view.container)).toEqual({ orbs: 3, discs: 0 }); + expect(colours(view.container)).toEqual([ + orbColourFor("helper"), + "ocean", + orbColourFor("another"), + ]); }); }); From 37c2f6c9f4af8be253297c395b78e0cd79ee2443 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Mon, 26 Oct 2026 16:43:49 -0700 Subject: [PATCH 2/2] Pick a Bot's orb colour with FNV-1a, so short ids spread across the colours With the running * 31 hash, your-agent, support-desk and the onboarding roster's ids all landed on one colour, so the roster read as OpenDuet beside two identical Bots. --- .../app/src/components/agents/abstract-avatar.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/openbot/app/src/components/agents/abstract-avatar.tsx b/openbot/app/src/components/agents/abstract-avatar.tsx index 760b4c1d..9148e34a 100644 --- a/openbot/app/src/components/agents/abstract-avatar.tsx +++ b/openbot/app/src/components/agents/abstract-avatar.tsx @@ -5,10 +5,19 @@ import { isTenantBot } from "@/lib/agents/primary-bot"; /** The orb's colours another Bot can wear: every one but Ocean, which is the tenant's own Bot's. */ const OTHER_COLOURS: readonly OrbColour[] = ["ember", "lime", "graphite"]; -/** The same colour for the same Bot, every time and everywhere it is drawn. */ +/** + * The same colour for the same Bot, every time and everywhere it is drawn. + * + * FNV-1a rather than a running `* 31`: short ids that differ only near their end, `your-agent` and + * `support-desk`, all landed on one colour with the simpler hash, and a roster of Bots in one colour + * says nothing about which is which. + */ export function orbColourFor(id: string): OrbColour { - let hash = 0; - for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + let hash = 0x811c9dc5; + for (const char of id) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 0x01000193) >>> 0; + } return OTHER_COLOURS[hash % OTHER_COLOURS.length] ?? "ember"; }