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
171 changes: 136 additions & 35 deletions app/src/features/bots/BotAvatar.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
// The Botloft mascot in the bot's own color (spec 15.3). The color only
// tells bots apart; state is shown elsewhere with an icon and a label.
//
// A bot's avatar has no background: just the mascot, with a thin edge so
// light colors keep their shape on the light theme. `framed` puts the
// mascot on the black square of the app icon, for Botloft itself (the
// title bar, messages from the daemon), whose white mascot needs it.
// The artwork (mascotArt.ts) is a flat body with soft shading layers clipped
// to its outline, re-tinted as a whole to the bot's color. A bot's avatar
// has no background: just the mascot, with a thin edge so light colors keep
// their shape on the light theme. `framed` puts the mascot on the black
// square of the app icon, for Botloft itself (the title bar, messages from
// the daemon), whose white mascot needs it.
//
// Given a `mood`, the mascot moves (motion.css): it blinks now and then,
// flickers while the bot works, hops while it waits for the owner and
// sleeps while paused. Without one it stays still, as in the chat history.
// Given a `mood`, the mascot comes alive (mascot.css): its flame burns,
// gently when idle and wildly, throwing embers, while it works; it hops
// while it waits for the owner, burns low with heavy eyes when tired and
// sleeps with its eyes shut while paused. Without one it stays still, as in
// the chat history.

import type { CSSProperties } from "react";
import { type CSSProperties, useId, useMemo } from "react";
import type { Bot } from "../../lib/protocol.gen";

const BODY =
"M468 58C600 105 730 180 800 290C830 340 845 400 870 440C885 465 915 475 930 455C948 430 945 400 938 378C1060 440 1170 600 1210 800C1240 950 1235 1120 1190 1254L70 1254L60 1254C10 1130 0 950 60 800C100 700 150 650 180 590C210 520 220 440 260 385C290 340 330 312 372 292C350 330 340 370 348 390C355 410 380 412 395 398C440 355 500 300 510 220C518 150 495 100 468 58Z";
import {
BODY,
CENTER,
type Ellipse,
EMBER,
EYE_GLOW,
EYES,
HIGHLIGHTS,
OUTLINE,
SHADES,
SPARKS,
VIEW_BOX,
} from "./mascotArt";
import { retint } from "./retint";

export type Mood = "idle" | "working" | "waiting" | "tired" | "sleeping";

Expand Down Expand Up @@ -45,6 +60,14 @@ function blinkDelay(color: string): string {
return `-${hash}ms`;
}

/** The glints that sit on `eye`. */
function glintsOn(eye: Ellipse): Ellipse[] {
const reach = Math.max(eye.rx, eye.ry) * 1.3;
return HIGHLIGHTS.filter((glint) => Math.hypot(glint.x - eye.x, glint.y - eye.y) < reach);
}

const rotate = ({ x, y, turn }: Ellipse) => `rotate(${turn} ${x} ${y})`;

export function BotAvatar({
color,
size = 28,
Expand All @@ -56,43 +79,121 @@ export function BotAvatar({
framed?: boolean;
mood?: Mood | undefined;
}) {
// Every avatar on the page needs its own clip, blur and gradients.
const unique = useId().replace(/[^\w-]/g, "");
const id = (name: string) => `avatar-${name}-${unique}`;
const paint = useMemo(
() => ({
body: retint(BODY, color),
shades: SHADES.map((shade) => retint(shade.fill, color)),
emberLight: retint(EMBER.light, color),
emberBase: retint(EMBER.base, color),
glow: retint(EYE_GLOW, color),
}),
[color],
);
const style = mood ? ({ "--blink-delay": blinkDelay(color) } as CSSProperties) : undefined;
const box = framed ? [CENTER.x - 500, CENTER.y - 470, 1000, 1000] : VIEW_BOX;
return (
<svg
aria-hidden
width={size}
height={size}
// A little room around the body, so its edge is not cut at the sides
// and a hop stays inside.
viewBox={framed ? "0 0 1254 1254" : "-24 -24 1302 1302"}
viewBox={box.join(" ")}
className={`shrink-0 overflow-visible ${framed ? "rounded-lg bg-[#0b0b0b]" : ""}`}
data-mood={mood}
style={style}
>
<g className="avatar-flame">
<path
d={BODY}
fill={color}
stroke={framed ? undefined : "var(--avatar-edge)"}
strokeWidth={framed ? undefined : 1}
vectorEffect="non-scaling-stroke"
/>
<g className="avatar-eye">
<ellipse cx="537" cy="862" rx="112" ry="110" fill="#0b0b0b" />
<ellipse cx="546" cy="811" rx="33" ry="29" fill="#ffffff" />
</g>
<g className="avatar-eye">
<ellipse
cx="945"
cy="768"
rx="98"
ry="104"
transform="rotate(-14 945 768)"
fill="#0b0b0b"
<defs>
<clipPath id={id("clip")}>
<path className="avatar-fire" d={OUTLINE} />
</clipPath>
<filter id={id("soft")} x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="4" />
</filter>
<linearGradient id={id("ember")} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={paint.emberLight} />
<stop offset="1" stopColor={paint.emberBase} />
</linearGradient>
<radialGradient id={id("eye")} cx="0.36" cy="0.95" r="0.75">
<stop offset="0" stopColor={paint.glow} />
<stop offset="0.3" stopColor="#3a0f05" />
<stop offset="1" stopColor="#120503" />
</radialGradient>
</defs>
<g className="avatar-hop">
{!framed && (
<path
className="avatar-fire"
d={OUTLINE}
fill="none"
stroke="var(--avatar-edge)"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
<ellipse cx="934" cy="720" rx="30" ry="26" fill="#ffffff" />
)}
<g clipPath={`url(#${id("clip")})`}>
<rect x={-200} y={0} width={1000} height={900} fill={paint.body} />
<g filter={`url(#${id("soft")})`}>
{SHADES.map((shade, index) => (
<path key={shade.fill} d={shade.d} fill={paint.shades[index]} />
))}
</g>
</g>
{mood === "working" &&
SPARKS.map((spark) => (
<g key={spark.x} transform={`translate(${spark.x} ${spark.y}) scale(1.5)`}>
<path
className="avatar-spark"
d={EMBER.d}
fill={`url(#${id("ember")})`}
style={
{
"--spark-delay": spark.delay,
"--spark-drift": spark.drift,
"--spark-turn": spark.turn,
} as CSSProperties
}
/>
</g>
))}
{EYES.map((eye, index) => (
<Eye key={eye.x} eye={eye} side={index === 0 ? "l" : "r"} fill={`url(#${id("eye")})`} />
))}
</g>
</svg>
);
}

/** One eye: open with its glints, or shut into a soft arc while asleep. */
function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string }) {
const { x, y, rx, ry } = eye;
return (
<g>
<g className={`avatar-eye avatar-eye-${side}`}>
<ellipse cx={x} cy={y} rx={rx} ry={ry} transform={rotate(eye)} fill={fill} />
<g className="avatar-hl">
{glintsOn(eye).map((glint) => (
<ellipse
key={glint.x}
cx={glint.x}
cy={glint.y}
rx={glint.rx}
ry={glint.ry}
transform={rotate(glint)}
fill="#ffffff"
/>
))}
</g>
</g>
<path
className="avatar-closed"
d={`M${x - rx * 0.8} ${y - 6}Q${x} ${y + ry * 0.62} ${x + rx * 0.8} ${y - 6}`}
fill="none"
stroke="#2a0d05"
strokeWidth={22}
strokeLinecap="round"
/>
</g>
);
}
10 changes: 4 additions & 6 deletions app/src/features/bots/BotStateBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
import {
Activity,
Archive,
CircleDot,
Hand,
Hourglass,
KeyRound,
LoaderCircle,
type LucideIcon,
Pause,
Power,
RotateCw,
} from "lucide-react";
import { type Messages, t, useT } from "../../i18n";
import type { Bot, BotState } from "../../lib/protocol.gen";
import { ReadyIcon, type StateIcon, WorkingIcon } from "./StateIcons";

type Tone = "ok" | "work" | "warn" | "danger" | "quiet";

export interface StateView {
label: string;
tone: Tone;
icon: LucideIcon;
icon: StateIcon;
spin?: boolean;
/** One sentence on what the state means for the owner. */
hint: string;
Expand All @@ -31,8 +29,8 @@ type Look = Omit<StateView, "label" | "hint">;
const looks: Record<BotState, Look> = {
offline: { tone: "quiet", icon: Power },
launching: { tone: "work", icon: LoaderCircle, spin: true },
idle: { tone: "ok", icon: CircleDot },
busy: { tone: "work", icon: Activity },
idle: { tone: "ok", icon: ReadyIcon },
busy: { tone: "work", icon: WorkingIcon },
needs_approval: { tone: "warn", icon: Hand },
rate_limited: { tone: "warn", icon: Hourglass },
auth_error: { tone: "danger", icon: KeyRound },
Expand Down
59 changes: 59 additions & 0 deletions app/src/features/bots/StateIcons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Two state icons that move (motion.css), where a still lucide icon would
// say less: a ready bot pulses, a working one runs a line. Both keep the
// lucide look (24 grid, 2px stroke) so they sit with the other states.

import type { ComponentType } from "react";

export interface StateIconProps {
size?: number;
className?: string;
"aria-hidden"?: boolean;
}

/** A state's icon: one of these, or any lucide icon. */
export type StateIcon = ComponentType<StateIconProps>;

/** "Ready": a dot with two rings spreading from it, one after the other. */
export function ReadyIcon({ size = 14, className = "" }: StateIconProps) {
return (
<svg
aria-hidden
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className={className}
>
<circle className="state-ring" cx="12" cy="12" r="5" />
<circle className="state-ring state-ring-late" cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none" />
</svg>
);
}

// The line of lucide's Activity, the icon this state had before it moved.
const PULSE =
"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2";

/** "Working": the pulse line, faint, with a bright stretch running along it. */
export function WorkingIcon({ size = 14, className = "" }: StateIconProps) {
return (
<svg
aria-hidden
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d={PULSE} opacity={0.3} />
<path className="state-trace" d={PULSE} pathLength={100} />
</svg>
);
}
88 changes: 88 additions & 0 deletions app/src/features/bots/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { BotAvatar } from "./BotAvatar";
import { BotStateBadge } from "./BotStateBadge";
import { BODY, DRAWN_IN, SHADES } from "./mascotArt";
import { retint, toHex, toHsl } from "./retint";

afterEach(cleanup);

const channels = (hex: string): [number, number, number] => {
const at = (index: number) => Number.parseInt(hex.slice(index, index + 2), 16);
return [at(1), at(3), at(5)];
};

describe("retint", () => {
test("hex and hsl round-trip", () => {
for (const hex of ["#ff7a59", "#5ec8ff", "#a48bff", "#9be564", "#e8d5b0", "#121212"]) {
expect(toHex(...toHsl(hex))).toBe(hex);
}
});

test("the color it was drawn in keeps the artwork as drawn", () => {
const drawnIn = toHex(DRAWN_IN.h, DRAWN_IN.s, DRAWN_IN.l);
for (const { fill } of SHADES) {
const [r, g, b] = channels(retint(fill, drawnIn));
const [r0, g0, b0] = channels(fill.toLowerCase());
expect(Math.abs(r - r0) + Math.abs(g - g0) + Math.abs(b - b0)).toBeLessThanOrEqual(6);
}
});

test("white leaves no hue, for Botloft's own white mascot", () => {
const [r, g, b] = channels(retint(BODY, "#ffffff"));
expect(r).toBe(g);
expect(g).toBe(b);
});
});

describe("mascot", () => {
test("every shading layer has its own color, which keys it", () => {
expect(new Set(SHADES.map((shade) => shade.fill)).size).toBe(SHADES.length);
});

test("each avatar clips to its own outline", () => {
const { container } = render(
<>
<BotAvatar color="#ff7a59" />
<BotAvatar color="#5ec8ff" />
</>,
);
const clips = [...container.querySelectorAll("clipPath")].map((clip) => clip.id);
expect(new Set(clips).size).toBe(2);
const used = [...container.querySelectorAll("[clip-path]")].map((g) =>
g.getAttribute("clip-path"),
);
expect(used).toEqual(clips.map((id) => `url(#${id})`));
});

test("the mood drives the motion, and only a working bot throws embers", () => {
const { container, rerender } = render(<BotAvatar color="#ff7a59" mood="idle" />);
const svg = () => container.querySelector("svg");
expect(svg()?.dataset.mood).toBe("idle");
expect(container.querySelectorAll(".avatar-spark")).toHaveLength(0);

rerender(<BotAvatar color="#ff7a59" mood="working" />);
expect(container.querySelectorAll(".avatar-spark")).toHaveLength(3);

rerender(<BotAvatar color="#ff7a59" />);
expect(svg()?.dataset.mood).toBeUndefined();
});

test("framed sits on the icon square without the thin edge", () => {
const { container } = render(<BotAvatar color="#ffffff" framed />);
expect(container.querySelector("svg")?.getAttribute("class")).toContain("bg-[#0b0b0b]");
expect(container.querySelector('[stroke="var(--avatar-edge)"]')).toBeNull();
});
});

describe("state badge", () => {
test("ready pulses and working runs its line", () => {
const { container, rerender } = render(
<BotStateBadge bot={{ state: "idle", paused: false }} />,
);
expect(container.querySelectorAll(".state-ring")).toHaveLength(2);

rerender(<BotStateBadge bot={{ state: "busy", paused: false }} />);
expect(container.querySelector(".state-trace")?.getAttribute("pathLength")).toBe("100");
});
});
Loading
Loading