diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 665ad485..08037884 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -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, @@ -55,6 +56,28 @@ export function moodOf(bot: Pick, 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; @@ -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, @@ -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 ( @@ -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} > @@ -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 ( @@ -196,14 +236,26 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string ))} - + {/* The arcs lean in a group of their own: the smile's CSS transform + would replace a transform set on the path itself. */} + + + + ); } diff --git a/app/src/features/bots/avatar.test.tsx b/app/src/features/bots/avatar.test.tsx index cc520e95..1466975c 100644 --- a/app/src/features/bots/avatar.test.tsx +++ b/app/src/features/bots/avatar.test.tsx @@ -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"; @@ -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(); + const cheering = () => container.querySelector("svg")?.hasAttribute("data-cheer"); + expect(container.querySelectorAll(".avatar-happy")).toHaveLength(2); + expect(cheering()).toBe(false); + + rerender(); + expect(cheering()).toBe(false); + rerender(); + expect(cheering()).toBe(true); + act(() => vi.advanceTimersByTime(CHEER_MS)); + expect(cheering()).toBe(false); + + rerender(); + rerender(); + expect(cheering()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + test("shut and smiling eyes lean with the line through the open ones", () => { + const { container } = render(); + 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(); expect(container.querySelector("svg")?.getAttribute("class")).toContain("bg-[#0b0b0b]"); diff --git a/app/src/index.css b/app/src/index.css index f3e6150e..22570cea 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -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"; diff --git a/app/src/mascot-cheer.css b/app/src/mascot-cheer.css new file mode 100644 index 00000000..bd646e6f --- /dev/null +++ b/app/src/mascot-cheer.css @@ -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); + } +} diff --git a/app/vite.config.ts b/app/vite.config.ts index 2c3441a1..043a2935 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -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/] }, }, })); diff --git a/docs/spec.md b/docs/spec.md index aaba5928..f6b88dfa 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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á.