From 3b3c59e20a5a4d51ea292a7c2d3a85c7eb19194f Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:39:46 -0300 Subject: [PATCH 1/2] feat(app): mascot cheers when its bot finishes When a bot goes from working to idle, its mascot crouches, jumps up stretched, lands squashed and smiles with its eyes bent into two arcs, then opens them again with a blink (1.2 s). A mascot that shows up idle does not cheer. The motion lives in its own file, mascot-cheer.css, and every frame ends at rest, so less motion shows nothing. --- app/src/features/bots/BotAvatar.tsx | 42 +++++++++++- app/src/features/bots/avatar.test.tsx | 41 ++++++++++- app/src/index.css | 1 + app/src/mascot-cheer.css | 98 +++++++++++++++++++++++++++ app/vite.config.ts | 6 +- docs/spec.md | 2 +- 6 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 app/src/mascot-cheer.css diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 665ad485..267f1942 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; @@ -99,6 +122,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 +134,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 +200,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 ( @@ -204,6 +232,14 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string strokeWidth={22} strokeLinecap="round" /> + ); } diff --git a/app/src/features/bots/avatar.test.tsx b/app/src/features/bots/avatar.test.tsx index cc520e95..cbd327cd 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,40 @@ 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("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..083a95ec 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. 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á. From 6d33186a7ccf0466643e23a05f99a9827ac8ad15 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:49:08 -0300 Subject: [PATCH 2/2] fix(app): lean the shut and smiling eyes with the face The open eyes sit on a line that rises to the right (about 14 degrees), but the arcs of the shut and smiling eyes were drawn level, so they looked crooked next to it. Both arcs now turn by the angle of that line, in a group of their own so the smile's CSS transform keeps the turn. --- app/src/features/bots/BotAvatar.tsx | 48 ++++++++++++++++++--------- app/src/features/bots/avatar.test.tsx | 8 +++++ docs/spec.md | 2 +- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 267f1942..08037884 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -95,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, @@ -224,22 +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 cbd327cd..1466975c 100644 --- a/app/src/features/bots/avatar.test.tsx +++ b/app/src/features/bots/avatar.test.tsx @@ -122,6 +122,14 @@ describe("mascot", () => { } }); + 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 diff --git a/docs/spec.md b/docs/spec.md index 083a95ec..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. 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. +- **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á.