diff --git a/openbot/app/src/components/agents/abstract-avatar.tsx b/openbot/app/src/components/agents/abstract-avatar.tsx index fe3db546..9148e34a 100644 --- a/openbot/app/src/components/agents/abstract-avatar.tsx +++ b/openbot/app/src/components/agents/abstract-avatar.tsx @@ -1,22 +1,45 @@ -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. + * + * 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 = 0x811c9dc5; + for (const char of id) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 0x01000193) >>> 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 +47,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"), + ]); }); });