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
25 changes: 16 additions & 9 deletions app/src/features/bots/BotAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@
// square of the app icon, for Botloft itself (the title bar, messages from
// the daemon), in the icon's own color.
//
// Given a `mood`, the mascot comes alive (mascot.css): its flame burns,
// 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. On top of the mood it makes short
// gestures once (mascotMoments.ts): it cheers when it finishes, wakes when it
// comes back, pops in when its bot was just created, and glances at the bot
// it talks to (useGlance.ts). Without a mood it stays still, as in the chat
// Given a `mood`, the mascot comes alive (mascot.css): its flame burns
// wildly, throwing embers, while it works; it sleeps as a small ember with
// its eyes shut while its bot has nothing to do, is paused or stopped; it
// hops as it starts to wait for the owner and burns low with heavy eyes when
// tired. Only the working flame moves on and on: everything else plays once.
// It makes short gestures (mascotMoments.ts): it cheers when it finishes
// and falls asleep, wakes when it has something to do, pops in when its bot
// was just created, and glances at the bot it talks to (useGlance.ts).
// Awake, as on the pages that are not a bot's, it stands with its eyes open
// and blinks now and then. Without a mood it stays still, as in the chat
// history.

import { type CSSProperties, useId, useMemo, useRef } from "react";
Expand All @@ -37,7 +39,11 @@ import { MOMENT_MS, useArrive, useMoment } from "./mascotMoments";
import { retint } from "./retint";
import { useGlance } from "./useGlance";

export type Mood = "idle" | "working" | "waiting" | "tired" | "sleeping";
/**
* `idle` is a bot with nothing to do: it sleeps, as a `sleeping` (paused
* or stopped) one does. `awake` is a mascot that is no bot's.
*/
export type Mood = "awake" | "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";
Expand Down Expand Up @@ -152,6 +158,7 @@ export function BotAvatar({
data-bot={botId}
data-cheer={moment === "cheer" || undefined}
data-wake={(moment === "wake" && !arrive) || undefined}
data-sleep={moment === "sleep" || undefined}
data-arrive={(mood && arrive) || undefined}
data-glance={glance ? "" : undefined}
style={style}
Expand Down
13 changes: 2 additions & 11 deletions app/src/features/bots/ListAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,7 @@ import type { Bot } from "../../lib/protocol.gen";
import { APP_OPENED } from "../../ui/motion";
import { BotAvatar, moodOf } from "./BotAvatar";

/**
* A bot's mascot in a list (the sidebar, a crew's cards): it moves while
* the bot is awake, idle too, since the owner reads an idle bot by its
* slow flame and glances. A sleeping one keeps its look but does not
* move: a morphing flame repaints every frame, and a stopped bot has
* nothing to show.
*/
/** A bot's mascot in a list (the sidebar, a crew's cards). */
export function ListAvatar({
bot,
crewPaused,
Expand All @@ -22,8 +16,6 @@ export function ListAvatar({
crewPaused: boolean;
size: number;
}) {
const mood = moodOf(bot, crewPaused);
const still = mood === "sleeping";
// A bot created a moment ago, while the app was open, pops in.
const [arriving] = useState(
() =>
Expand All @@ -35,8 +27,7 @@ export function ListAvatar({
<BotAvatar
color={bot.color}
size={size}
mood={mood}
still={still}
mood={moodOf(bot, crewPaused)}
botId={bot.id}
arriving={arriving}
starting={bot.state === "launching"}
Expand Down
25 changes: 18 additions & 7 deletions app/src/features/bots/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import moodRules from "../../mascot.css?raw";
import cheerFrames from "../../mascot-cheer.css?raw";
import flameFrames from "../../mascot-flame.css?raw";
import { BOTLOFT_COLOR, BotAvatar, CHEER_MS } from "./BotAvatar";
Expand Down Expand Up @@ -52,18 +53,29 @@ describe("mascot", () => {
expect(commands(OUTLINE).match(/M/g)).toHaveLength(2);
});

test("every flame frame keeps the commands of the outline, so it morphs", () => {
test("every flame shape 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);
expect(frames.length).toBeGreaterThan(15);
for (const frame of frames) {
expect(commands(frame)).toBe(commands(OUTLINE));
}
});

test("only a working bot's mascot moves on and on", () => {
const forever = [...moodRules.matchAll(/([^{}]+)\{[^{}]*infinite[^{}]*\}/g)].map((rule) =>
(rule[1] ?? "").trim(),
);
expect(forever).toEqual([
'[data-mood="working"] .avatar-fire',
'[data-mood="working"] .avatar-spark',
]);
expect(flameFrames).not.toContain("infinite");
});

test("the eyes glance together, and the shading keeps its holes", () => {
const { container } = render(<BotAvatar color="#ff7a59" mood="idle" />);
const { container } = render(<BotAvatar color="#ff7a59" mood="awake" />);
expect(container.querySelectorAll(".avatar-look .avatar-eye")).toHaveLength(2);
const shades = container.querySelectorAll("[filter] path");
expect(shades).toHaveLength(SHADES.length);
Expand Down Expand Up @@ -114,6 +126,8 @@ describe("mascot", () => {
expect(cheering()).toBe(true);
act(() => vi.advanceTimersByTime(CHEER_MS));
expect(cheering()).toBe(false);
// Done cheering, it falls asleep.
expect(container.querySelector("svg")?.hasAttribute("data-sleep")).toBe(true);

rerender(<BotAvatar color="#ff7a59" mood="waiting" />);
rerender(<BotAvatar color="#ff7a59" mood="idle" />);
Expand Down Expand Up @@ -179,20 +193,17 @@ describe("ListAvatar", () => {
paused: false,
});

test("a sleeping bot keeps its look but does not move; an idle or working one moves", () => {
test("shows the bot's mood: idle, stopped or working", () => {
const { container, rerender } = render(
<ListAvatar bot={bot("idle")} crewPaused={false} size={32} />,
);
const svg = () => container.querySelector("svg") as SVGElement;
expect(svg().dataset.mood).toBe("idle");
expect(svg().hasAttribute("data-still")).toBe(false);

rerender(<ListAvatar bot={bot("offline")} crewPaused={false} size={32} />);
expect(svg().dataset.mood).toBe("sleeping");
expect(svg().hasAttribute("data-still")).toBe(true);

rerender(<ListAvatar bot={bot("busy")} crewPaused={false} size={32} />);
expect(svg().dataset.mood).toBe("working");
expect(svg().hasAttribute("data-still")).toBe(false);
});
});
23 changes: 13 additions & 10 deletions app/src/features/bots/eyeMoments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,36 +25,39 @@ afterEach(() => {
});

describe("the eyes' moments", () => {
test("awake mascots blink for a moment, then stand still", () => {
test("mascots with open eyes blink for a moment, then stand still", () => {
const working = mascot("working");
const idle = mascot("idle");
const sleeping = mascot("sleeping");
const still = mascot("idle", true);
const still = mascot("working", true);
// Every draw says yes, with no spread.
stop = startEyeMoments(() => 0);
// A moment starts on its own timer, right after the tick.
vi.advanceTimersByTime(2001);
expect(idle.hasAttribute("data-blink")).toBe(true);
expect(working.hasAttribute("data-blink")).toBe(true);
// A bot with nothing to do sleeps, as a stopped one does.
expect(idle.hasAttribute("data-blink")).toBe(false);
expect(sleeping.hasAttribute("data-blink")).toBe(false);
expect(still.hasAttribute("data-blink")).toBe(false);
vi.advanceTimersByTime(300);
expect(idle.hasAttribute("data-blink")).toBe(false);
expect(working.hasAttribute("data-blink")).toBe(false);
});

test("one idle mascot looks around now and then, not on every tick", () => {
const idle = mascot("idle");
test("one awake mascot looks around now and then, not on every tick", () => {
const awake = mascot("awake");
const working = mascot("working");
stop = startEyeMoments(() => 0.99);
vi.advanceTimersByTime(8000);
expect(idle.hasAttribute("data-look")).toBe(false);
expect(awake.hasAttribute("data-look")).toBe(false);
vi.advanceTimersByTime(2001);
expect(idle.hasAttribute("data-look")).toBe(true);
expect(awake.hasAttribute("data-look")).toBe(true);
expect(working.hasAttribute("data-look")).toBe(false);
vi.advanceTimersByTime(4500);
expect(idle.hasAttribute("data-look")).toBe(false);
expect(awake.hasAttribute("data-look")).toBe(false);
});

test("nothing moves while the window has no focus", () => {
const idle = mascot("idle");
const idle = mascot("awake");
document.documentElement.setAttribute("data-blurred", "");
stop = startEyeMoments(() => 0);
vi.advanceTimersByTime(20_000);
Expand Down
19 changes: 10 additions & 9 deletions app/src/features/bots/eyeMoments.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// When the mascots blink and glance around (spec 15.3). An animation that
// runs forever makes the WebView draw every frame, so the eyes do not run
// one: every couple of seconds this picks which mascots on screen blink,
// each a little apart, and now and then one idle mascot looks around.
// each a little apart, and now and then one awake mascot (one that is no
// bot's) looks around. A bot with nothing to do sleeps: its eyes are shut.
// Between those moments the eyes stand still and cost nothing. Paused while
// the window is out of sight or has no focus, and with less motion.

Expand All @@ -14,7 +15,7 @@ const SPREAD_MS = 600;
/** A blink, as `eye-blink` in mascot.css; a tired droop takes longer. */
const BLINK_MS = 260;
const DROOP_MS = 800;
/** One idle mascot looks around, at most this often, for this long. */
/** One awake mascot looks around, at most this often, for this long. */
const LOOK_EVERY_MS = [9000, 16000] as const;
const LOOK_MS = 4400;

Expand All @@ -29,10 +30,10 @@ function resting(): boolean {
);
}

/** The mascots that may move their eyes: on screen, awake, not held still. */
function awake(): SVGElement[] {
/** The mascots that may move their eyes: on screen, with them open, not held still. */
function open(): SVGElement[] {
const all = document.querySelectorAll<SVGElement>(
'svg[data-mood]:not([data-mood="sleeping"]):not([data-still])',
'svg[data-mood]:not([data-mood="idle"], [data-mood="sleeping"], [data-still])',
);
return [...all].filter((svg) => {
const box = svg.getBoundingClientRect();
Expand All @@ -55,17 +56,17 @@ export function startEyeMoments(random: () => number = Math.random): () => void
if (resting()) {
return;
}
const mascots = awake();
const mascots = open();
for (const svg of mascots) {
if (random() < TICK_MS / BLINK_EVERY_MS) {
const tired = svg.getAttribute("data-mood") === "tired";
moment(svg, "data-blink", random() * SPREAD_MS, tired ? DROOP_MS : BLINK_MS);
}
}
const now = performance.now();
const idle = mascots.filter((svg) => svg.getAttribute("data-mood") === "idle");
if (now >= nextLook && idle.length > 0) {
const svg = idle[Math.floor(random() * idle.length)] as SVGElement;
const awake = mascots.filter((svg) => svg.getAttribute("data-mood") === "awake");
if (now >= nextLook && awake.length > 0) {
const svg = awake[Math.floor(random() * awake.length)] as SVGElement;
moment(svg, "data-look", 0, LOOK_MS);
const [least, most] = LOOK_EVERY_MS;
nextLook = now + least + random() * (most - least);
Expand Down
39 changes: 26 additions & 13 deletions app/src/features/bots/mascotMoments.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
// The mascot's moments (spec 15.3): short gestures it makes once, on top of
// its mood, when something happens to its bot. It cheers when the bot
// finishes what it was doing (mascot-cheer.css), wakes when it comes back
// from paused or stopped, and pops in when the bot was just created
// (mascot-wake.css). Each lasts as long as its animation, then the mood
// takes over again.
// its mood, when something happens to its bot. Only a working bot's flame
// moves on and on; every change between the other moods is one of these.
// It cheers when the bot finishes what it was doing (mascot-cheer.css),
// wakes when it has something to do or comes back from paused or stopped,
// pops in when the bot was just created (mascot-wake.css), and falls asleep
// when it has nothing left to do (mascot-sleep.css). Each lasts as long as
// its animation, then the mood takes over again.

import { useEffect, useState } from "react";
import type { Mood } from "./BotAvatar";

export type Moment = "cheer" | "wake";
export type Moment = "cheer" | "wake" | "sleep";

/** How long each moment lasts, as in its CSS. */
export const MOMENT_MS: Record<Moment, number> = { cheer: 1200, wake: 800 };
export const MOMENT_MS: Record<Moment, number> = { cheer: 1200, wake: 800, sleep: 900 };

/** How long a new bot's mascot pops in, as in mascot-wake.css. */
export const ARRIVE_MS = 700;
Expand All @@ -22,19 +24,29 @@ interface Seen {
starting: boolean;
}

/** A bot with nothing to do sleeps as a paused or stopped one does. */
export function asleep(mood: Mood | undefined): boolean {
return mood === "idle" || mood === "sleeping";
}

/**
* The moment a change of mood calls for, if any. Only a bot that finished
* real work cheers: one that just finished starting up does not.
* real work cheers: one that just finished starting up does not. One
* turned back on wakes, even when it has nothing to do and goes back to
* sleep right after.
*/
function momentOf(last: Seen, mood: Mood | undefined): Moment | null {
if (last.mood === undefined || mood === undefined) return null;
if (last.mood === "working" && !last.starting && mood === "idle") return "cheer";
if (last.mood === "sleeping" && mood !== undefined && mood !== "sleeping") return "wake";
return null;
if (last.mood === "sleeping" && mood !== "sleeping") return "wake";
if (asleep(last.mood)) return asleep(mood) ? null : "wake";
return asleep(mood) ? "sleep" : null;
}

/**
* The moment the mascot is in now: from the change of mood that calls for
* it, for as long as it lasts. One that shows up in a mood makes none.
* it, for as long as it lasts. One that shows up in a mood makes none. A
* mascot left asleep by its moment (a cheer, a wake) then falls asleep.
*/
export function useMoment(mood: Mood | undefined, starting = false): Moment | null {
const [last, setLast] = useState<Seen>({ mood, starting });
Expand All @@ -45,9 +57,10 @@ export function useMoment(mood: Mood | undefined, starting = false): Moment | nu
}
useEffect(() => {
if (!moment) return;
const timer = setTimeout(() => setMoment(null), MOMENT_MS[moment]);
const next = moment !== "sleep" && asleep(mood) ? "sleep" : null;
const timer = setTimeout(() => setMoment(next), MOMENT_MS[moment]);
return () => clearTimeout(timer);
}, [moment]);
}, [moment, mood]);
return moment;
}

Expand Down
32 changes: 32 additions & 0 deletions app/src/features/bots/moments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import type { BotId, Message, MessageId } from "../../lib/protocol.gen";
import glanceFrames from "../../mascot-glance.css?raw";
import sleepFrames from "../../mascot-sleep.css?raw";
import wakeFrames from "../../mascot-wake.css?raw";
import { crewOpened, renderApp } from "../../test/app";
import { APP_OPENED } from "../../ui/motion";
Expand Down Expand Up @@ -32,6 +33,33 @@ describe("moments", () => {
expect(svgOf(container).hasAttribute("data-wake")).toBe(false);
});

test("falls asleep once when it has nothing left to do", () => {
vi.useFakeTimers();
const { container, rerender } = render(<BotAvatar color="#5ec8ff" mood="working" starting />);
const sleeping = () => svgOf(container).hasAttribute("data-sleep");
rerender(<BotAvatar color="#5ec8ff" mood="idle" />);
expect(sleeping()).toBe(true);
act(() => vi.advanceTimersByTime(MOMENT_MS.sleep));
expect(sleeping()).toBe(false);

// Paused while it had nothing to do, it was asleep already.
rerender(<BotAvatar color="#5ec8ff" mood="sleeping" />);
expect(sleeping()).toBe(false);
expect(svgOf(container).hasAttribute("data-wake")).toBe(false);
});

test("turned back on, it wakes, then goes back to sleep with nothing to do", () => {
vi.useFakeTimers();
const { container, rerender } = render(<BotAvatar color="#5ec8ff" mood="sleeping" />);
rerender(<BotAvatar color="#5ec8ff" mood="idle" />);
expect(svgOf(container).hasAttribute("data-wake")).toBe(true);
act(() => vi.advanceTimersByTime(MOMENT_MS.wake));
expect(svgOf(container).hasAttribute("data-wake")).toBe(false);
expect(svgOf(container).hasAttribute("data-sleep")).toBe(true);
act(() => vi.advanceTimersByTime(MOMENT_MS.sleep));
expect(svgOf(container).hasAttribute("data-sleep")).toBe(false);
});

test("does not cheer when it only finished starting up", () => {
const { container, rerender } = render(<BotAvatar color="#5ec8ff" mood="working" starting />);
rerender(<BotAvatar color="#5ec8ff" mood="idle" />);
Expand Down Expand Up @@ -96,6 +124,10 @@ describe("moments", () => {
expect(end(wakeFrames, "arrive-pop")).toMatch(/opacity: 1;\s*transform: scale\(1, 1\);/);
expect(end(wakeFrames, "arrive-open")).toContain("clip-path: inset(0);");
expect(end(glanceFrames, "glance")).toContain("transform: translate(0, 0);");
// Falling asleep ends asleep: the eyes shut into the arcs.
expect(end(sleepFrames, "sleep-slump")).toContain("transform: scale(1, 1);");
expect(end(sleepFrames, "sleep-shut")).toContain("opacity: 0;");
expect(end(sleepFrames, "sleep-arcs")).toContain("opacity: 1;");
});
});

Expand Down
2 changes: 1 addition & 1 deletion app/src/features/onboarding/Welcome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function Welcome() {
<main className="min-h-0 flex-1 overflow-y-auto p-8">
<div className="mx-auto max-w-xl">
<span className="mb-3 inline-block animate-float">
<BotAvatar color="#ff7a59" size={56} mood="idle" />
<BotAvatar color="#ff7a59" size={56} mood="awake" />
</span>
<h1 className="font-semibold text-2xl tracking-tight">{w.title}</h1>
<p className="mt-1 text-ink-soft leading-relaxed">{w.intro}</p>
Expand Down
1 change: 1 addition & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
@import "./mascot-flame.css";
@import "./mascot-cheer.css";
@import "./mascot-wake.css";
@import "./mascot-sleep.css";
@import "./mascot-glance.css";
@import "./panels.css";
@import "./sidebar.css";
Expand Down
Loading
Loading