Skip to content

Commit ea4917f

Browse files
authored
Merge pull request #34 from httpsphl/feat/mascot-motion
feat: shaded mascot with a burning flame; ready and working icons move
2 parents 8e2a2e0 + 02a8ca9 commit ea4917f

11 files changed

Lines changed: 891 additions & 115 deletions

File tree

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

13-
import type { CSSProperties } from "react";
17+
import { type CSSProperties, useId, useMemo } from "react";
1418
import type { Bot } from "../../lib/protocol.gen";
15-
16-
const BODY =
17-
"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";
19+
import {
20+
BODY,
21+
CENTER,
22+
type Ellipse,
23+
EMBER,
24+
EYE_GLOW,
25+
EYES,
26+
HIGHLIGHTS,
27+
OUTLINE,
28+
SHADES,
29+
SPARKS,
30+
VIEW_BOX,
31+
} from "./mascotArt";
32+
import { retint } from "./retint";
1833

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

@@ -45,6 +60,14 @@ function blinkDelay(color: string): string {
4560
return `-${hash}ms`;
4661
}
4762

63+
/** The glints that sit on `eye`. */
64+
function glintsOn(eye: Ellipse): Ellipse[] {
65+
const reach = Math.max(eye.rx, eye.ry) * 1.3;
66+
return HIGHLIGHTS.filter((glint) => Math.hypot(glint.x - eye.x, glint.y - eye.y) < reach);
67+
}
68+
69+
const rotate = ({ x, y, turn }: Ellipse) => `rotate(${turn} ${x} ${y})`;
70+
4871
export function BotAvatar({
4972
color,
5073
size = 28,
@@ -56,43 +79,121 @@ export function BotAvatar({
5679
framed?: boolean;
5780
mood?: Mood | undefined;
5881
}) {
82+
// Every avatar on the page needs its own clip, blur and gradients.
83+
const unique = useId().replace(/[^\w-]/g, "");
84+
const id = (name: string) => `avatar-${name}-${unique}`;
85+
const paint = useMemo(
86+
() => ({
87+
body: retint(BODY, color),
88+
shades: SHADES.map((shade) => retint(shade.fill, color)),
89+
emberLight: retint(EMBER.light, color),
90+
emberBase: retint(EMBER.base, color),
91+
glow: retint(EYE_GLOW, color),
92+
}),
93+
[color],
94+
);
5995
const style = mood ? ({ "--blink-delay": blinkDelay(color) } as CSSProperties) : undefined;
96+
const box = framed ? [CENTER.x - 500, CENTER.y - 470, 1000, 1000] : VIEW_BOX;
6097
return (
6198
<svg
6299
aria-hidden
63100
width={size}
64101
height={size}
65-
// A little room around the body, so its edge is not cut at the sides
66-
// and a hop stays inside.
67-
viewBox={framed ? "0 0 1254 1254" : "-24 -24 1302 1302"}
102+
viewBox={box.join(" ")}
68103
className={`shrink-0 overflow-visible ${framed ? "rounded-lg bg-[#0b0b0b]" : ""}`}
69104
data-mood={mood}
70105
style={style}
71106
>
72-
<g className="avatar-flame">
73-
<path
74-
d={BODY}
75-
fill={color}
76-
stroke={framed ? undefined : "var(--avatar-edge)"}
77-
strokeWidth={framed ? undefined : 1}
78-
vectorEffect="non-scaling-stroke"
79-
/>
80-
<g className="avatar-eye">
81-
<ellipse cx="537" cy="862" rx="112" ry="110" fill="#0b0b0b" />
82-
<ellipse cx="546" cy="811" rx="33" ry="29" fill="#ffffff" />
83-
</g>
84-
<g className="avatar-eye">
85-
<ellipse
86-
cx="945"
87-
cy="768"
88-
rx="98"
89-
ry="104"
90-
transform="rotate(-14 945 768)"
91-
fill="#0b0b0b"
107+
<defs>
108+
<clipPath id={id("clip")}>
109+
<path className="avatar-fire" d={OUTLINE} />
110+
</clipPath>
111+
<filter id={id("soft")} x="-10%" y="-10%" width="120%" height="120%">
112+
<feGaussianBlur stdDeviation="4" />
113+
</filter>
114+
<linearGradient id={id("ember")} x1="0" y1="0" x2="0" y2="1">
115+
<stop offset="0" stopColor={paint.emberLight} />
116+
<stop offset="1" stopColor={paint.emberBase} />
117+
</linearGradient>
118+
<radialGradient id={id("eye")} cx="0.36" cy="0.95" r="0.75">
119+
<stop offset="0" stopColor={paint.glow} />
120+
<stop offset="0.3" stopColor="#3a0f05" />
121+
<stop offset="1" stopColor="#120503" />
122+
</radialGradient>
123+
</defs>
124+
<g className="avatar-hop">
125+
{!framed && (
126+
<path
127+
className="avatar-fire"
128+
d={OUTLINE}
129+
fill="none"
130+
stroke="var(--avatar-edge)"
131+
strokeWidth={2}
132+
vectorEffect="non-scaling-stroke"
92133
/>
93-
<ellipse cx="934" cy="720" rx="30" ry="26" fill="#ffffff" />
134+
)}
135+
<g clipPath={`url(#${id("clip")})`}>
136+
<rect x={-200} y={0} width={1000} height={900} fill={paint.body} />
137+
<g filter={`url(#${id("soft")})`}>
138+
{SHADES.map((shade, index) => (
139+
<path key={shade.fill} d={shade.d} fill={paint.shades[index]} />
140+
))}
141+
</g>
94142
</g>
143+
{mood === "working" &&
144+
SPARKS.map((spark) => (
145+
<g key={spark.x} transform={`translate(${spark.x} ${spark.y}) scale(1.5)`}>
146+
<path
147+
className="avatar-spark"
148+
d={EMBER.d}
149+
fill={`url(#${id("ember")})`}
150+
style={
151+
{
152+
"--spark-delay": spark.delay,
153+
"--spark-drift": spark.drift,
154+
"--spark-turn": spark.turn,
155+
} as CSSProperties
156+
}
157+
/>
158+
</g>
159+
))}
160+
{EYES.map((eye, index) => (
161+
<Eye key={eye.x} eye={eye} side={index === 0 ? "l" : "r"} fill={`url(#${id("eye")})`} />
162+
))}
95163
</g>
96164
</svg>
97165
);
98166
}
167+
168+
/** One eye: open with its glints, or shut into a soft arc while asleep. */
169+
function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string }) {
170+
const { x, y, rx, ry } = eye;
171+
return (
172+
<g>
173+
<g className={`avatar-eye avatar-eye-${side}`}>
174+
<ellipse cx={x} cy={y} rx={rx} ry={ry} transform={rotate(eye)} fill={fill} />
175+
<g className="avatar-hl">
176+
{glintsOn(eye).map((glint) => (
177+
<ellipse
178+
key={glint.x}
179+
cx={glint.x}
180+
cy={glint.y}
181+
rx={glint.rx}
182+
ry={glint.ry}
183+
transform={rotate(glint)}
184+
fill="#ffffff"
185+
/>
186+
))}
187+
</g>
188+
</g>
189+
<path
190+
className="avatar-closed"
191+
d={`M${x - rx * 0.8} ${y - 6}Q${x} ${y + ry * 0.62} ${x + rx * 0.8} ${y - 6}`}
192+
fill="none"
193+
stroke="#2a0d05"
194+
strokeWidth={22}
195+
strokeLinecap="round"
196+
/>
197+
</g>
198+
);
199+
}

‎app/src/features/bots/BotStateBadge.tsx‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,23 @@
11
import {
2-
Activity,
32
Archive,
4-
CircleDot,
53
Hand,
64
Hourglass,
75
KeyRound,
86
LoaderCircle,
9-
type LucideIcon,
107
Pause,
118
Power,
129
RotateCw,
1310
} from "lucide-react";
1411
import { type Messages, t, useT } from "../../i18n";
1512
import type { Bot, BotState } from "../../lib/protocol.gen";
13+
import { ReadyIcon, type StateIcon, WorkingIcon } from "./StateIcons";
1614

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

1917
export interface StateView {
2018
label: string;
2119
tone: Tone;
22-
icon: LucideIcon;
20+
icon: StateIcon;
2321
spin?: boolean;
2422
/** One sentence on what the state means for the owner. */
2523
hint: string;
@@ -31,8 +29,8 @@ type Look = Omit<StateView, "label" | "hint">;
3129
const looks: Record<BotState, Look> = {
3230
offline: { tone: "quiet", icon: Power },
3331
launching: { tone: "work", icon: LoaderCircle, spin: true },
34-
idle: { tone: "ok", icon: CircleDot },
35-
busy: { tone: "work", icon: Activity },
32+
idle: { tone: "ok", icon: ReadyIcon },
33+
busy: { tone: "work", icon: WorkingIcon },
3634
needs_approval: { tone: "warn", icon: Hand },
3735
rate_limited: { tone: "warn", icon: Hourglass },
3836
auth_error: { tone: "danger", icon: KeyRound },
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Two state icons that move (motion.css), where a still lucide icon would
2+
// say less: a ready bot pulses, a working one runs a line. Both keep the
3+
// lucide look (24 grid, 2px stroke) so they sit with the other states.
4+
5+
import type { ComponentType } from "react";
6+
7+
export interface StateIconProps {
8+
size?: number;
9+
className?: string;
10+
"aria-hidden"?: boolean;
11+
}
12+
13+
/** A state's icon: one of these, or any lucide icon. */
14+
export type StateIcon = ComponentType<StateIconProps>;
15+
16+
/** "Ready": a dot with two rings spreading from it, one after the other. */
17+
export function ReadyIcon({ size = 14, className = "" }: StateIconProps) {
18+
return (
19+
<svg
20+
aria-hidden
21+
width={size}
22+
height={size}
23+
viewBox="0 0 24 24"
24+
fill="none"
25+
stroke="currentColor"
26+
strokeWidth={2}
27+
className={className}
28+
>
29+
<circle className="state-ring" cx="12" cy="12" r="5" />
30+
<circle className="state-ring state-ring-late" cx="12" cy="12" r="5" />
31+
<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none" />
32+
</svg>
33+
);
34+
}
35+
36+
// The line of lucide's Activity, the icon this state had before it moved.
37+
const PULSE =
38+
"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";
39+
40+
/** "Working": the pulse line, faint, with a bright stretch running along it. */
41+
export function WorkingIcon({ size = 14, className = "" }: StateIconProps) {
42+
return (
43+
<svg
44+
aria-hidden
45+
width={size}
46+
height={size}
47+
viewBox="0 0 24 24"
48+
fill="none"
49+
stroke="currentColor"
50+
strokeWidth={2}
51+
strokeLinecap="round"
52+
strokeLinejoin="round"
53+
className={className}
54+
>
55+
<path d={PULSE} opacity={0.3} />
56+
<path className="state-trace" d={PULSE} pathLength={100} />
57+
</svg>
58+
);
59+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { cleanup, render } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { BotAvatar } from "./BotAvatar";
4+
import { BotStateBadge } from "./BotStateBadge";
5+
import { BODY, DRAWN_IN, SHADES } from "./mascotArt";
6+
import { retint, toHex, toHsl } from "./retint";
7+
8+
afterEach(cleanup);
9+
10+
const channels = (hex: string): [number, number, number] => {
11+
const at = (index: number) => Number.parseInt(hex.slice(index, index + 2), 16);
12+
return [at(1), at(3), at(5)];
13+
};
14+
15+
describe("retint", () => {
16+
test("hex and hsl round-trip", () => {
17+
for (const hex of ["#ff7a59", "#5ec8ff", "#a48bff", "#9be564", "#e8d5b0", "#121212"]) {
18+
expect(toHex(...toHsl(hex))).toBe(hex);
19+
}
20+
});
21+
22+
test("the color it was drawn in keeps the artwork as drawn", () => {
23+
const drawnIn = toHex(DRAWN_IN.h, DRAWN_IN.s, DRAWN_IN.l);
24+
for (const { fill } of SHADES) {
25+
const [r, g, b] = channels(retint(fill, drawnIn));
26+
const [r0, g0, b0] = channels(fill.toLowerCase());
27+
expect(Math.abs(r - r0) + Math.abs(g - g0) + Math.abs(b - b0)).toBeLessThanOrEqual(6);
28+
}
29+
});
30+
31+
test("white leaves no hue, for Botloft's own white mascot", () => {
32+
const [r, g, b] = channels(retint(BODY, "#ffffff"));
33+
expect(r).toBe(g);
34+
expect(g).toBe(b);
35+
});
36+
});
37+
38+
describe("mascot", () => {
39+
test("every shading layer has its own color, which keys it", () => {
40+
expect(new Set(SHADES.map((shade) => shade.fill)).size).toBe(SHADES.length);
41+
});
42+
43+
test("each avatar clips to its own outline", () => {
44+
const { container } = render(
45+
<>
46+
<BotAvatar color="#ff7a59" />
47+
<BotAvatar color="#5ec8ff" />
48+
</>,
49+
);
50+
const clips = [...container.querySelectorAll("clipPath")].map((clip) => clip.id);
51+
expect(new Set(clips).size).toBe(2);
52+
const used = [...container.querySelectorAll("[clip-path]")].map((g) =>
53+
g.getAttribute("clip-path"),
54+
);
55+
expect(used).toEqual(clips.map((id) => `url(#${id})`));
56+
});
57+
58+
test("the mood drives the motion, and only a working bot throws embers", () => {
59+
const { container, rerender } = render(<BotAvatar color="#ff7a59" mood="idle" />);
60+
const svg = () => container.querySelector("svg");
61+
expect(svg()?.dataset.mood).toBe("idle");
62+
expect(container.querySelectorAll(".avatar-spark")).toHaveLength(0);
63+
64+
rerender(<BotAvatar color="#ff7a59" mood="working" />);
65+
expect(container.querySelectorAll(".avatar-spark")).toHaveLength(3);
66+
67+
rerender(<BotAvatar color="#ff7a59" />);
68+
expect(svg()?.dataset.mood).toBeUndefined();
69+
});
70+
71+
test("framed sits on the icon square without the thin edge", () => {
72+
const { container } = render(<BotAvatar color="#ffffff" framed />);
73+
expect(container.querySelector("svg")?.getAttribute("class")).toContain("bg-[#0b0b0b]");
74+
expect(container.querySelector('[stroke="var(--avatar-edge)"]')).toBeNull();
75+
});
76+
});
77+
78+
describe("state badge", () => {
79+
test("ready pulses and working runs its line", () => {
80+
const { container, rerender } = render(
81+
<BotStateBadge bot={{ state: "idle", paused: false }} />,
82+
);
83+
expect(container.querySelectorAll(".state-ring")).toHaveLength(2);
84+
85+
rerender(<BotStateBadge bot={{ state: "busy", paused: false }} />);
86+
expect(container.querySelector(".state-trace")?.getAttribute("pathLength")).toBe("100");
87+
});
88+
});

0 commit comments

Comments
 (0)