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
74 changes: 63 additions & 11 deletions app/src/features/bots/BotAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
// 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
// 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.

import { type CSSProperties, useId, useMemo } from "react";
import { type CSSProperties, useEffect, useId, useMemo, useState } from "react";
import type { Bot } from "../../lib/protocol.gen";
import {
BODY,
Expand Down Expand Up @@ -55,6 +56,28 @@ 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;
}

/** A delay from the color, so a crew's mascots do not blink together. */
function blinkDelay(color: string): string {
let hash = 0;
Expand All @@ -72,6 +95,18 @@ function glintsOn(eye: Ellipse): Ellipse[] {

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

/**
* How far the face leans, in degrees: the line through the two eyes, the
* right one sitting higher. Shut and smiling eyes lean with it.
*/
function tiltOf(eyes: readonly Ellipse[]): number {
const [left, right] = eyes;
if (!left || !right) return 0;
return (Math.atan2(right.y - left.y, right.x - left.x) * 180) / Math.PI;
}

const TILT = tiltOf(EYES);

export function BotAvatar({
color,
size = 28,
Expand Down Expand Up @@ -99,6 +134,7 @@ export function BotAvatar({
}),
[color],
);
const cheer = useCheer(mood);
const style = mood ? ({ "--blink-delay": blinkDelay(color) } as CSSProperties) : undefined;
const box = framed ? [CENTER.x - 500, CENTER.y - 470, 1000, 1000] : VIEW_BOX;
return (
Expand All @@ -110,6 +146,7 @@ export function BotAvatar({
className={`shrink-0 overflow-visible ${framed ? "rounded-lg bg-[#0b0b0b]" : ""}`}
data-mood={mood}
data-still={still || undefined}
data-cheer={cheer || undefined}
style={style}
>
<defs>
Expand Down Expand Up @@ -175,7 +212,10 @@ export function BotAvatar({
);
}

/** One eye: open with its glints, or shut into a soft arc while asleep. */
/**
* One eye: open with its glints, shut into a soft arc while asleep, or
* bent up into a smile while the mascot cheers.
*/
function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string }) {
const { x, y, rx, ry } = eye;
return (
Expand All @@ -196,14 +236,26 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string
))}
</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"
/>
{/* The arcs lean in a group of their own: the smile's CSS transform
would replace a transform set on the path itself. */}
<g transform={`rotate(${TILT} ${x} ${y})`}>
<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"
/>
<path
className="avatar-happy"
d={`M${x - rx * 0.78} ${y + ry * 0.3}Q${x} ${y - ry * 1.3} ${x + rx * 0.78} ${y + ry * 0.3}`}
fill="none"
stroke="#2a0d05"
strokeWidth={22}
strokeLinecap="round"
/>
</g>
</g>
);
}
Expand Down
49 changes: 46 additions & 3 deletions app/src/features/bots/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
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, ListAvatar } from "./BotAvatar";
import { BOTLOFT_COLOR, BotAvatar, CHEER_MS, ListAvatar } from "./BotAvatar";
import { BotStateBadge } from "./BotStateBadge";
import { BODY, DRAWN_IN, OUTLINE, SHADES } from "./mascotArt";
import { retint, toHex, toHsl } from "./retint";
Expand Down Expand Up @@ -98,6 +99,48 @@ describe("mascot", () => {
expect(svg()?.dataset.mood).toBeUndefined();
});

test("cheers once when its bot finishes, not when it shows up idle", () => {
vi.useFakeTimers();
try {
const { container, rerender } = render(<BotAvatar color="#ff7a59" mood="idle" />);
const cheering = () => container.querySelector("svg")?.hasAttribute("data-cheer");
expect(container.querySelectorAll(".avatar-happy")).toHaveLength(2);
expect(cheering()).toBe(false);

rerender(<BotAvatar color="#ff7a59" mood="working" />);
expect(cheering()).toBe(false);
rerender(<BotAvatar color="#ff7a59" mood="idle" />);
expect(cheering()).toBe(true);
act(() => vi.advanceTimersByTime(CHEER_MS));
expect(cheering()).toBe(false);

rerender(<BotAvatar color="#ff7a59" mood="waiting" />);
rerender(<BotAvatar color="#ff7a59" mood="idle" />);
expect(cheering()).toBe(false);
} finally {
vi.useRealTimers();
}
});

test("shut and smiling eyes lean with the line through the open ones", () => {
const { container } = render(<BotAvatar color="#ff7a59" mood="sleeping" />);
for (const arc of container.querySelectorAll(".avatar-closed, .avatar-happy")) {
const turn = arc.parentElement?.getAttribute("transform")?.match(/^rotate\((-?[\d.]+) /);
expect(Number(turn?.[1])).toBeCloseTo(-14.3, 0);
}
});

test("the cheer ends at rest, so less motion shows none of it", () => {
const end = (name: string) =>
cheerFrames
.match(new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`))?.[1]
?.split(/100% \{/)
.at(-1);
expect(end("cheer-hop")).toContain("transform: translateY(0) scale(1, 1);");
expect(end("cheer-open")).toMatch(/opacity: 1;\s*clip-path: inset\(0\);/);
expect(end("cheer-smile")).toContain("opacity: 0;");
});

test("framed sits on the icon square without the thin edge", () => {
const { container } = render(<BotAvatar color={BOTLOFT_COLOR} framed />);
expect(container.querySelector("svg")?.getAttribute("class")).toContain("bg-[#0b0b0b]");
Expand Down
1 change: 1 addition & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
@import "./motion.css";
@import "./mascot.css";
@import "./mascot-flame.css";
@import "./mascot-cheer.css";
@import "./panels.css";
@import "./sidebar.css";
@import "./dialog.css";
Expand Down
98 changes: 98 additions & 0 deletions app/src/mascot-cheer.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/* The mascot cheers once when the bot finishes what it was doing
(BotAvatar.tsx sets data-cheer for CHEER_MS, spec 15.3): it crouches,
jumps up stretched, lands squashed and settles, while its eyes turn into
two arcs smiling up and come back open with a blink. Every frame ends at
rest, so less motion, which cuts the animation to its end, shows nothing. */

@layer components {
.avatar-happy {
opacity: 0;
transform-box: fill-box;
transform-origin: 50% 100%;
}

[data-mood="idle"][data-cheer] .avatar-hop {
animation: cheer-hop 1.2s both;
}

[data-mood="idle"][data-cheer] .avatar-eye {
animation: cheer-open 1.2s both;
}

[data-mood="idle"][data-cheer] .avatar-happy {
animation: cheer-smile 1.2s both;
}

[data-mood="idle"][data-cheer] .avatar-look {
animation: none;
}
}

/* Up eases out, the fall eases in, and the landing gives a little. */
@keyframes cheer-hop {
0% {
transform: translateY(0) scale(1, 1);
animation-timing-function: ease-in-out;
}
12% {
transform: translateY(0) scale(1.1, 0.9);
animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1);
}
30% {
transform: translateY(-16%) scale(0.93, 1.08);
animation-timing-function: cubic-bezier(0.6, 0, 0.8, 0.4);
}
46% {
transform: translateY(0) scale(1.1, 0.9);
animation-timing-function: ease-out;
}
58% {
transform: translateY(-3%) scale(0.97, 1.03);
animation-timing-function: ease-in-out;
}
70%,
100% {
transform: translateY(0) scale(1, 1);
}
}

/* The open eyes give way to the smile, then open again from the middle. */
@keyframes cheer-open {
0%,
20% {
opacity: 1;
clip-path: inset(0);
}
24%,
84% {
opacity: 0;
clip-path: inset(50% 0);
}
92%,
100% {
opacity: 1;
clip-path: inset(0);
}
}

@keyframes cheer-smile {
0%,
20% {
opacity: 0;
transform: scale(1, 0.4);
}
24% {
opacity: 1;
transform: scale(1, 0.4);
}
32%,
82% {
opacity: 1;
transform: scale(1, 1);
}
86%,
100% {
opacity: 0;
transform: scale(1, 1);
}
}
6 changes: 3 additions & 3 deletions app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export default defineConfig(({ mode }) => ({
test: {
environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"],
// The mascot test reads the flame frames as text, and the messages test
// the color tokens; other CSS stays empty.
css: { include: [/mascot-flame\.css/, /src\/index\.css/] },
// The mascot test reads the flame and cheer frames as text, and the
// messages test the color tokens; other CSS stays empty.
css: { include: [/mascot-(flame|cheer)\.css/, /src\/index\.css/] },
},
}));
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -871,7 +871,7 @@ Tamanho: a janela inteira é desenhada numa escala (zoom do webview, `setZoom`),

Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animações curtas (150 a 400 ms, saída suave) em CSS, sem biblioteca (`motion.css`), e todas param quando o Windows desliga os efeitos de animação (`prefers-reduced-motion`) ou quando o dono liga "Menos animações" nas Configurações (`motion-less.css` repete as mesmas regras para `:root[data-motion="less"]`, e `reducedMotion()` olha os dois).

- **Mascote:** cada bot tem um humor que segue o estado, e a chama queima de verdade: o contorno muda de forma quadro a quadro e o sombreado acompanha (`mascot.css`, `mascot-flame.css`). Parado, a chama balança devagar e os olhos dão uma olhada para baixo e para o lado de vez em quando, como nas poses do desenho; trabalhando (`busy`, `launching`), queima forte e solta faíscas pelo alto; esperando o dono (`needs_approval`), inclina rápido e dá pulinhos; no limite de uso, queima baixo, de pálpebras pesadas que caem de vez em quando; pausado ou parado, vira uma brasa pequena e dorme, de olhos fechados em dois arcos. Os olhos piscam fechando de cima e de baixo, cada bot num ritmo, para uma equipe não piscar junta. Mexem-se os mascotes do cabeçalho e da resposta em andamento, e os da barra lateral e dos cartões da crew enquanto o bot está acordado, parado também: é pela chama devagar e pelas olhadas que o dono vê de relance que ele está livre. Nessas listas, só um bot dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro e um bot desligado não tem o que mostrar. O histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto, menos o de parado e livre (`idle`), que não aparece: a chama devagar já diz isso.
- **Mascote:** cada bot tem um humor que segue o estado, e a chama queima de verdade: o contorno muda de forma quadro a quadro e o sombreado acompanha (`mascot.css`, `mascot-flame.css`). Parado, a chama balança devagar e os olhos dão uma olhada para baixo e para o lado de vez em quando, como nas poses do desenho; trabalhando (`busy`, `launching`), queima forte e solta faíscas pelo alto; esperando o dono (`needs_approval`), inclina rápido e dá pulinhos; no limite de uso, queima baixo, de pálpebras pesadas que caem de vez em quando; pausado ou parado, vira uma brasa pequena e dorme, de olhos fechados em dois arcos, inclinados como os olhos abertos. Quando termina o que fazia (de trabalhando para parado), comemora uma vez (`mascot-cheer.css`, 1,2 s): agacha, dá um pulo que estica a chama, pousa achatando, e os olhos viram dois arcos para cima, sorrindo, até voltarem abertos numa piscada; um mascote que já aparece parado não comemora. Os olhos piscam fechando de cima e de baixo, cada bot num ritmo, para uma equipe não piscar junta. Mexem-se os mascotes do cabeçalho e da resposta em andamento, e os da barra lateral e dos cartões da crew enquanto o bot está acordado, parado também: é pela chama devagar e pelas olhadas que o dono vê de relance que ele está livre. Nessas listas, só um bot dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro e um bot desligado não tem o que mostrar. O histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto, menos o de parado e livre (`idle`), que não aparece: a chama devagar já diz isso.
- **Chegadas:** o que chega enquanto o dono olha entra subindo e aparecendo: mensagens, ferramentas, pedidos, avisos e bots novos na barra lateral. O que já estava lá quando ele abriu o chat (ou o app) aparece parado, e páginas antigas carregadas em cima não se mexem. A resposta já aparece enquanto é escrita, então não anima de novo ao terminar.
- **Trabalhando:** os três pontos pulam em sequência; o texto sendo escrito tem um cursor piscando no fim; a mão de "Precisa de aprovação" acena; um pedido pendente pulsa uma vez ao aparecer. O estado de um bot que sai de parado aparece suave (o que já estava na tela ao abrir fica parado), e na linha de "Trabalhando" corre um trecho aceso (`StateIcons.tsx`); com menos movimento, a linha fica inteira acesa.
- **Trocar de bot ou de crew:** o painel principal some rápido e o novo sobe (View Transitions do webview); a barra lateral fica onde está.
Expand Down
Loading