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
49 changes: 34 additions & 15 deletions app/app-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/128x128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/128x128@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/64x64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/Square107x107Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/Square142x142Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/Square150x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/Square284x284Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/Square30x30Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/Square310x310Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/Square44x44Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/Square71x71Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/Square89x89Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/StoreLogo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src-tauri/icons/icon.icns
Binary file not shown.
Binary file modified app/src-tauri/icons/icon.ico
Binary file not shown.
Binary file modified app/src-tauri/icons/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 16 additions & 10 deletions app/src/features/bots/BotAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
// 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 daemon), in the icon's own color.
//
// Given a `mood`, the mascot comes alive (mascot.css): its flame burns,
// gently when idle and wildly, throwing embers, while it works; it hops
// gently when idle, glancing around now and then, 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.
Expand All @@ -33,6 +34,9 @@ import { retint } from "./retint";

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

/** Botloft's own mascot, as on the app icon: the color that shows the drawing as drawn. */
export const BOTLOFT_COLOR = "#FF7A59";

/** How the mascot of `bot` moves, from what the bot is doing. */
export function moodOf(bot: Pick<Bot, "state" | "paused">, crewPaused = false): Mood {
if (bot.paused || crewPaused || bot.state === "offline" || bot.state === "archived") {
Expand Down Expand Up @@ -109,16 +113,16 @@ export function BotAvatar({
<path className="avatar-fire" d={OUTLINE} />
</clipPath>
<filter id={id("soft")} x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="4" />
<feGaussianBlur stdDeviation="1.8" />
</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">
<radialGradient id={id("eye")} cx="0.42" cy="1" r="0.72">
<stop offset="0" stopColor={paint.glow} />
<stop offset="0.3" stopColor="#3a0f05" />
<stop offset="1" stopColor="#120503" />
<stop offset="0.45" stopColor="#2a0805" />
<stop offset="1" stopColor="#0a0101" />
</radialGradient>
</defs>
<g className="avatar-hop">
Expand All @@ -136,7 +140,7 @@ export function BotAvatar({
<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]} />
<path key={shade.fill} d={shade.d} fill={paint.shades[index]} fillRule="evenodd" />
))}
</g>
</g>
Expand All @@ -157,9 +161,11 @@ export function BotAvatar({
/>
</g>
))}
{EYES.map((eye, index) => (
<Eye key={eye.x} eye={eye} side={index === 0 ? "l" : "r"} fill={`url(#${id("eye")})`} />
))}
<g className="avatar-look">
{EYES.map((eye, index) => (
<Eye key={eye.x} eye={eye} side={index === 0 ? "l" : "r"} fill={`url(#${id("eye")})`} />
))}
</g>
</g>
</svg>
);
Expand Down
48 changes: 39 additions & 9 deletions app/src/features/bots/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { BotAvatar } from "./BotAvatar";
import flameFrames from "../../mascot-flame.css?raw";
import { BOTLOFT_COLOR, BotAvatar } from "./BotAvatar";
import { BotStateBadge } from "./BotStateBadge";
import { BODY, DRAWN_IN, SHADES } from "./mascotArt";
import { BODY, DRAWN_IN, OUTLINE, SHADES } from "./mascotArt";
import { retint, toHex, toHsl } from "./retint";

afterEach(cleanup);

/** The commands of a path, which must match for the browser to morph it. */
const commands = (d: string) => d.replace(/[^MCZ]/g, "");

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)];
Expand All @@ -19,16 +23,18 @@ describe("retint", () => {
}
});

test("the color it was drawn in keeps the artwork as drawn", () => {
test("the color it was drawn in, Botloft's own, 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);
for (const color of [drawnIn, BOTLOFT_COLOR]) {
for (const { fill } of SHADES) {
const [r, g, b] = channels(retint(fill, color));
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", () => {
test("white leaves no hue", () => {
const [r, g, b] = channels(retint(BODY, "#ffffff"));
expect(r).toBe(g);
expect(g).toBe(b);
Expand All @@ -40,6 +46,30 @@ describe("mascot", () => {
expect(new Set(SHADES.map((shade) => shade.fill)).size).toBe(SHADES.length);
});

test("the outline is the body and the drop of fire above it", () => {
expect(commands(OUTLINE).match(/M/g)).toHaveLength(2);
});

test("every flame frame keeps the commands of the outline, so it morphs", () => {
const frames = [...flameFrames.matchAll(/d: path\(\s*"([^"]+)"/g)].map(
(match) => match[1] ?? "",
);
expect(frames.length).toBeGreaterThan(30);
for (const frame of frames) {
expect(commands(frame)).toBe(commands(OUTLINE));
}
});

test("the eyes glance together, and the shading keeps its holes", () => {
const { container } = render(<BotAvatar color="#ff7a59" mood="idle" />);
expect(container.querySelectorAll(".avatar-look .avatar-eye")).toHaveLength(2);
const shades = container.querySelectorAll("[filter] path");
expect(shades).toHaveLength(SHADES.length);
for (const shade of shades) {
expect(shade.getAttribute("fill-rule")).toBe("evenodd");
}
});

test("each avatar clips to its own outline", () => {
const { container } = render(
<>
Expand Down Expand Up @@ -69,7 +99,7 @@ describe("mascot", () => {
});

test("framed sits on the icon square without the thin edge", () => {
const { container } = render(<BotAvatar color="#ffffff" framed />);
const { container } = render(<BotAvatar color={BOTLOFT_COLOR} framed />);
expect(container.querySelector("svg")?.getAttribute("class")).toContain("bg-[#0b0b0b]");
expect(container.querySelector('[stroke="var(--avatar-edge)"]')).toBeNull();
});
Expand Down
90 changes: 50 additions & 40 deletions app/src/features/bots/mascotArt.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions app/src/features/chat/InboundRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { when } from "../../lib/format";
import type { Bot, Message } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { useArrival } from "../../ui/motion";
import { BotAvatar } from "../bots/BotAvatar";
import { BOTLOFT_COLOR, BotAvatar } from "../bots/BotAvatar";
import { DeliveryStatus } from "../messages/DeliveryStatus";
import { AttachmentList } from "./AttachmentList";

Expand Down Expand Up @@ -76,7 +76,7 @@ function OtherMessage({ message }: { message: Message }) {
return (
<li className={`flex gap-3 pr-12 ${arrival}`}>
<BotAvatar
color={system ? "#ffffff" : (sender?.color ?? "#6f6f69")}
color={system ? BOTLOFT_COLOR : (sender?.color ?? "#6f6f69")}
size={28}
framed={system}
/>
Expand Down
4 changes: 2 additions & 2 deletions app/src/features/messages/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useT } from "../../i18n";
import { fromNow, when } from "../../lib/format";
import type { Bot, BotId, Message, Task } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { BotAvatar } from "../bots/BotAvatar";
import { BOTLOFT_COLOR, BotAvatar } from "../bots/BotAvatar";
import { DeliveryStatus } from "./DeliveryStatus";

function BotName({ bot }: { bot: Bot | undefined }) {
Expand Down Expand Up @@ -32,7 +32,7 @@ function Sender({ message, bots }: { message: Message; bots: Record<BotId, Bot>
case "system":
return (
<span className="inline-flex items-center gap-1.5 font-medium">
<BotAvatar color="#ffffff" size={14} framed />
<BotAvatar color={BOTLOFT_COLOR} size={14} framed />
{t.messages.row.system}
</span>
);
Expand Down
Loading
Loading