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
84 changes: 35 additions & 49 deletions app/src/features/bots/BotAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
// 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. When it finishes what it was
// doing, it cheers once (mascot-cheer.css). Without one it stays still, as in
// the chat history.
// 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
// history.

import { type CSSProperties, useEffect, useId, useMemo, useState } from "react";
import type { Bot } from "../../lib/protocol.gen";
import { type CSSProperties, useId, useMemo, useRef } from "react";
import type { Bot, BotId } from "../../lib/protocol.gen";
import {
BODY,
CENTER,
Expand All @@ -31,7 +33,9 @@ import {
SPARKS,
VIEW_BOX,
} from "./mascotArt";
import { MOMENT_MS, useArrive, useMoment } from "./mascotMoments";
import { retint } from "./retint";
import { useGlance } from "./useGlance";

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

Expand All @@ -57,26 +61,7 @@ export function moodOf(bot: Pick<Bot, "state" | "paused">, crewPaused = false):
}

/** How long the mascot cheers when its bot finishes, as in mascot-cheer.css. */
export const CHEER_MS = 1200;

/**
* Whether the mascot cheers now: from the moment its mood goes from working
* to idle, for CHEER_MS. One that shows up idle does not.
*/
function useCheer(mood: Mood | undefined): boolean {
const [last, setLast] = useState(mood);
const [cheer, setCheer] = useState(false);
if (mood !== last) {
setLast(mood);
setCheer(last === "working" && mood === "idle");
}
useEffect(() => {
if (!cheer) return;
const timer = setTimeout(() => setCheer(false), CHEER_MS);
return () => clearTimeout(timer);
}, [cheer]);
return cheer;
}
export const CHEER_MS = MOMENT_MS.cheer;

/** A delay from the color, so a crew's mascots do not blink together. */
function blinkDelay(color: string): string {
Expand Down Expand Up @@ -113,13 +98,22 @@ export function BotAvatar({
framed = false,
mood,
still = false,
botId,
arriving = false,
starting = false,
}: {
color: string;
size?: number;
framed?: boolean;
mood?: Mood | undefined;
/** Shows the mood without moving. */
still?: boolean;
/** Whose mascot this is, so it can glance at the bots it talks to. */
botId?: BotId | undefined;
/** Pops in: its bot was just created. */
arriving?: boolean;
/** Its bot is starting up: done, it does not cheer as for finished work. */
starting?: boolean;
}) {
// Every avatar on the page needs its own clip, blur and gradients.
const unique = useId().replace(/[^\w-]/g, "");
Expand All @@ -134,19 +128,32 @@ export function BotAvatar({
}),
[color],
);
const cheer = useCheer(mood);
const style = mood ? ({ "--blink-delay": blinkDelay(color) } as CSSProperties) : undefined;
const svg = useRef<SVGSVGElement>(null);
const moment = useMoment(mood, starting);
const arrive = useArrive(arriving);
const glance = useGlance(mood && !still ? botId : undefined, svg);
const style = mood
? ({
"--blink-delay": blinkDelay(color),
...(glance && { "--glance-x": `${glance.x}px`, "--glance-y": `${glance.y}px` }),
} as CSSProperties)
: undefined;
const box = framed ? [CENTER.x - 500, CENTER.y - 470, 1000, 1000] : VIEW_BOX;
return (
<svg
ref={svg}
aria-hidden
width={size}
height={size}
viewBox={box.join(" ")}
className={`shrink-0 overflow-visible ${framed ? "rounded-lg bg-[#0b0b0b]" : ""}`}
data-mood={mood}
data-still={still || undefined}
data-cheer={cheer || undefined}
data-bot={botId}
data-cheer={moment === "cheer" || undefined}
data-wake={(moment === "wake" && !arrive) || undefined}
data-arrive={(mood && arrive) || undefined}
data-glance={glance ? "" : undefined}
style={style}
>
<defs>
Expand Down Expand Up @@ -259,24 +266,3 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string
</g>
);
}

/**
* 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.
*/
export function ListAvatar({
bot,
crewPaused,
size,
}: {
bot: Pick<Bot, "color" | "state" | "paused">;
crewPaused: boolean;
size: number;
}) {
const mood = moodOf(bot, crewPaused);
const still = mood === "sleeping";
return <BotAvatar color={bot.color} size={size} mood={mood} still={still} />;
}
8 changes: 7 additions & 1 deletion app/src/features/bots/BotHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,13 @@ export function BotHeader({
return (
// In a narrow window the handle gives way first, then the button labels.
<header className="@container flex items-center gap-3 border-line border-b px-5 py-2.5">
<BotAvatar color={bot.color} size={36} mood={moodOf(bot, crew.paused)} />
<BotAvatar
color={bot.color}
size={36}
mood={moodOf(bot, crew.paused)}
botId={bot.id}
starting={bot.state === "launching"}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<h1 className="truncate font-semibold text-lg tracking-tight">{bot.name}</h1>
Expand Down
48 changes: 48 additions & 0 deletions app/src/features/bots/ListAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// A bot's mascot in a list (the sidebar, a crew's cards), with the bot's
// mood, its glances and, for a bot just created, its pop in (BotAvatar.tsx).

import { useState } from "react";
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.
*/
export function ListAvatar({
bot,
crewPaused,
size,
}: {
bot: Pick<Bot, "color" | "state" | "paused"> & Partial<Pick<Bot, "id" | "createdAt">>;
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(
() =>
bot.createdAt !== undefined &&
bot.createdAt > APP_OPENED &&
Date.now() - bot.createdAt < ARRIVING_FOR_MS,
);
return (
<BotAvatar
color={bot.color}
size={size}
mood={mood}
still={still}
botId={bot.id}
arriving={arriving}
starting={bot.state === "launching"}
/>
);
}

/** How long after its creation a bot's mascot still pops in when it shows. */
const ARRIVING_FOR_MS = 10_000;
3 changes: 2 additions & 1 deletion app/src/features/bots/avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import cheerFrames from "../../mascot-cheer.css?raw";
import flameFrames from "../../mascot-flame.css?raw";
import { BOTLOFT_COLOR, BotAvatar, CHEER_MS, ListAvatar } from "./BotAvatar";
import { BOTLOFT_COLOR, BotAvatar, CHEER_MS } from "./BotAvatar";
import { BotStateBadge } from "./BotStateBadge";
import { ListAvatar } from "./ListAvatar";
import { BODY, DRAWN_IN, OUTLINE, SHADES } from "./mascotArt";
import { retint, toHex, toHsl } from "./retint";

Expand Down
63 changes: 63 additions & 0 deletions app/src/features/bots/mascotMoments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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.

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

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

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

/** How long a new bot's mascot pops in, as in mascot-wake.css. */
export const ARRIVE_MS = 700;

interface Seen {
mood: Mood | undefined;
/** The bot was starting up (launching), which also shows as working. */
starting: boolean;
}

/**
* 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.
*/
function momentOf(last: Seen, mood: Mood | undefined): Moment | null {
if (last.mood === "working" && !last.starting && mood === "idle") return "cheer";
if (last.mood === "sleeping" && mood !== undefined && mood !== "sleeping") return "wake";
return 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.
*/
export function useMoment(mood: Mood | undefined, starting = false): Moment | null {
const [last, setLast] = useState<Seen>({ mood, starting });
const [moment, setMoment] = useState<Moment | null>(null);
if (mood !== last.mood || starting !== last.starting) {
setLast({ mood, starting });
if (mood !== last.mood) setMoment(momentOf(last, mood));
}
useEffect(() => {
if (!moment) return;
const timer = setTimeout(() => setMoment(null), MOMENT_MS[moment]);
return () => clearTimeout(timer);
}, [moment]);
return moment;
}

/** Whether the mascot is still popping in, when it showed up `arriving`. */
export function useArrive(arriving: boolean): boolean {
const [now, setNow] = useState(arriving);
useEffect(() => {
if (!now) return;
const timer = setTimeout(() => setNow(false), ARRIVE_MS);
return () => clearTimeout(timer);
}, [now]);
return now;
}
Loading
Loading