Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 32 additions & 16 deletions openbot/app/src/components/agents/abstract-avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,58 @@
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);
return (
<span
role="img"
aria-label={name}
className={cn(
"inline-flex shrink-0",
!own && "overflow-hidden rounded-full",
)}
className="inline-flex shrink-0"
style={{ height: size, width: size }}
>
{/* The drawing carries its own role; hidden so the coworker is announced once, by name. */}
<span aria-hidden="true" className="contents">
{own ? (
<MicOrb size={size} still />
) : (
<Avatar name={seed} size={size} />
)}
<MicOrb colour={own ? "ocean" : orbColourFor(id)} size={size} still />
</span>
</span>
);
Expand Down
15 changes: 9 additions & 6 deletions openbot/app/src/components/channels/avatar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -110,10 +111,12 @@ function GroupFaces({
}

function Face({ id, size }: { id: string; size: number }) {
return isTenantBot(id) ? (
<MicOrb size={size} still />
) : (
<Avatar className="size-full" name={id} size={size} />
return (
<MicOrb
colour={isTenantBot(id) ? "ocean" : orbColourFor(id)}
size={size}
still
/>
);
}

Expand Down
85 changes: 63 additions & 22 deletions openbot/app/src/components/duet/mic-orb-draw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof typeof OCEAN, Rgb>;
/** 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<Tone, Rgb> & { shade: string };
const paletteMemo = new Map<string, Palette>();

/** 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);
}
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions openbot/app/src/components/duet/mic-orb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
FINISH_S,
mulberry32,
nodCurve,
type OrbColour,
type OrbPose,
restLook,
type Track,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand All @@ -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 }}
>
Expand Down
7 changes: 6 additions & 1 deletion openbot/app/src/components/onboarding/illustration-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,7 +31,7 @@ export function IllustrationFrame({
className?: string;
}) {
return (
<div aria-label={label} className="w-full max-w-lg" role="img">
<div aria-label={label} className="mx-auto w-full max-w-lg" role="img">
<div
className={`pointer-events-none select-none rounded-2xl border border-border bg-muted/60 ${className}`}
inert
Expand Down
39 changes: 31 additions & 8 deletions openbot/app/tests/tenant-bot-avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import { cleanup, render } from "@testing-library/react";
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import {
AbstractAvatar,
orbColourFor,
} from "@/components/agents/abstract-avatar";
import { ChannelAvatar } from "@/components/channels/avatar";
import { isTenantBot } from "@/lib/agents/primary-bot";
import { appConfig } from "@/lib/generated/application-config";
Expand Down Expand Up @@ -105,6 +108,12 @@ const drawings = (element: Element) => ({
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("<AbstractAvatar>", () => {
test("draws the tenant's own Bot as the mic orb, its glow unclipped", () => {
const view = render(
Expand All @@ -116,13 +125,21 @@ describe("<AbstractAvatar>", () => {
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(
<AbstractAvatar id="helper" name="Helper" seed={own} size={28} />,
);
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));
}
});
});

Expand All @@ -132,15 +149,21 @@ describe("<ChannelAvatar>", () => {
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(<ChannelAvatar participantIds={["helper"]} />);
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(
<ChannelAvatar participantIds={["helper", own, "another"]} />,
);
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"),
]);
});
});
Loading