From 4d5f2162e373fc9dda560783292799fd4f7ae898 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:47:55 -0300 Subject: [PATCH 1/2] feat(app): mascots wake, pop in and look at each other Three more one-time gestures on top of the mood: - wake: when a bot comes back from paused or stopped, the ember grows into the flame, stretching past it, and the shut eyes give way to open ones that open halfway, blink heavily and open for good (0.8 s); - pop in: a bot created while the app is open shows in the sidebar and the crew's cards growing from a dot, landing squashed and opening its eyes (0.7 s); - glance: when one bot sends another a message, both mascots look toward the nearest mascot of the other on screen for a moment (1.6 s). The moments move to mascotMoments.ts and ListAvatar to its own file, so BotAvatar stays short. Each animation ends at rest, so less motion shows nothing. --- app/src/features/bots/BotAvatar.tsx | 81 +++++++---------- app/src/features/bots/BotHeader.tsx | 2 +- app/src/features/bots/ListAvatar.tsx | 47 ++++++++++ app/src/features/bots/avatar.test.tsx | 3 +- app/src/features/bots/mascotMoments.ts | 54 ++++++++++++ app/src/features/bots/moments.test.tsx | 112 +++++++++++++++++++++++ app/src/features/bots/useGlance.ts | 70 +++++++++++++++ app/src/features/chat/ChatView.tsx | 2 +- app/src/features/crews/CrewBots.tsx | 2 +- app/src/features/crews/Sidebar.tsx | 2 +- app/src/index.css | 2 + app/src/mascot-glance.css | 25 ++++++ app/src/mascot-wake.css | 117 +++++++++++++++++++++++++ app/src/store/context.tsx | 5 ++ app/vite.config.ts | 4 +- docs/spec.md | 2 +- 16 files changed, 473 insertions(+), 57 deletions(-) create mode 100644 app/src/features/bots/ListAvatar.tsx create mode 100644 app/src/features/bots/mascotMoments.ts create mode 100644 app/src/features/bots/moments.test.tsx create mode 100644 app/src/features/bots/useGlance.ts create mode 100644 app/src/mascot-glance.css create mode 100644 app/src/mascot-wake.css diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 08037884..595a0f7c 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -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, @@ -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"; @@ -57,26 +61,7 @@ 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; -} +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 { @@ -113,6 +98,8 @@ export function BotAvatar({ framed = false, mood, still = false, + botId, + arriving = false, }: { color: string; size?: number; @@ -120,6 +107,10 @@ export function BotAvatar({ 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; }) { // Every avatar on the page needs its own clip, blur and gradients. const unique = useId().replace(/[^\w-]/g, ""); @@ -134,11 +125,20 @@ export function BotAvatar({ }), [color], ); - const cheer = useCheer(mood); - const style = mood ? ({ "--blink-delay": blinkDelay(color) } as CSSProperties) : undefined; + const svg = useRef(null); + const moment = useMoment(mood); + 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 ( @@ -259,24 +263,3 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string ); } - -/** - * 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; - crewPaused: boolean; - size: number; -}) { - const mood = moodOf(bot, crewPaused); - const still = mood === "sleeping"; - return ; -} diff --git a/app/src/features/bots/BotHeader.tsx b/app/src/features/bots/BotHeader.tsx index e54427dc..39777c40 100644 --- a/app/src/features/bots/BotHeader.tsx +++ b/app/src/features/bots/BotHeader.tsx @@ -77,7 +77,7 @@ export function BotHeader({ return ( // In a narrow window the handle gives way first, then the button labels.
- +

{bot.name}

diff --git a/app/src/features/bots/ListAvatar.tsx b/app/src/features/bots/ListAvatar.tsx new file mode 100644 index 00000000..b935fa24 --- /dev/null +++ b/app/src/features/bots/ListAvatar.tsx @@ -0,0 +1,47 @@ +// 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 & Partial>; + 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 ( + + ); +} + +/** How long after its creation a bot's mascot still pops in when it shows. */ +const ARRIVING_FOR_MS = 10_000; diff --git a/app/src/features/bots/avatar.test.tsx b/app/src/features/bots/avatar.test.tsx index 1466975c..8b40c0a9 100644 --- a/app/src/features/bots/avatar.test.tsx +++ b/app/src/features/bots/avatar.test.tsx @@ -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"; diff --git a/app/src/features/bots/mascotMoments.ts b/app/src/features/bots/mascotMoments.ts new file mode 100644 index 00000000..72a18723 --- /dev/null +++ b/app/src/features/bots/mascotMoments.ts @@ -0,0 +1,54 @@ +// 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 = { cheer: 1200, wake: 800 }; + +/** How long a new bot's mascot pops in, as in mascot-wake.css. */ +export const ARRIVE_MS = 700; + +/** The moment a change of mood calls for, if any. */ +function momentOf(last: Mood | undefined, mood: Mood | undefined): Moment | null { + if (last === "working" && mood === "idle") return "cheer"; + if (last === "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): Moment | null { + const [last, setLast] = useState(mood); + const [moment, setMoment] = useState(null); + if (mood !== last) { + setLast(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; +} diff --git a/app/src/features/bots/moments.test.tsx b/app/src/features/bots/moments.test.tsx new file mode 100644 index 00000000..fc70b73f --- /dev/null +++ b/app/src/features/bots/moments.test.tsx @@ -0,0 +1,112 @@ +import { act, cleanup, render, waitFor } from "@testing-library/react"; +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 wakeFrames from "../../mascot-wake.css?raw"; +import { crewOpened, renderApp } from "../../test/app"; +import { APP_OPENED } from "../../ui/motion"; +import { BotAvatar } from "./BotAvatar"; +import { ListAvatar } from "./ListAvatar"; +import { ARRIVE_MS, MOMENT_MS } from "./mascotMoments"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +const svgOf = (container: HTMLElement) => container.querySelector("svg") as SVGSVGElement; + +describe("moments", () => { + test("wakes when its bot comes back, not when it shows up awake", () => { + vi.useFakeTimers(); + const { container, rerender } = render(); + expect(svgOf(container).hasAttribute("data-wake")).toBe(false); + + rerender(); + expect(svgOf(container).hasAttribute("data-wake")).toBe(false); + rerender(); + expect(svgOf(container).hasAttribute("data-wake")).toBe(true); + act(() => vi.advanceTimersByTime(MOMENT_MS.wake)); + expect(svgOf(container).hasAttribute("data-wake")).toBe(false); + }); + + test("a bot created a moment ago pops in once; an older one does not", () => { + vi.useFakeTimers(); + vi.setSystemTime(APP_OPENED + 60_000); + const bot = (createdAt: number) => ({ + id: "bot_new" as BotId, + color: "#9be564", + state: "launching" as const, + paused: false, + createdAt, + }); + const fresh = render( + , + ); + expect(svgOf(fresh.container).hasAttribute("data-arrive")).toBe(true); + act(() => vi.advanceTimersByTime(ARRIVE_MS)); + expect(svgOf(fresh.container).hasAttribute("data-arrive")).toBe(false); + + const old = render(); + expect(svgOf(old.container).hasAttribute("data-arrive")).toBe(false); + }); + + test("every moment ends at rest, so less motion shows none of it", () => { + const end = (frames: string, name: string) => + frames + .match(new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`))?.[1] + ?.split(/100% \{/) + .at(-1); + expect(end(wakeFrames, "wake-up")).toContain("transform: scale(1, 1);"); + expect(end(wakeFrames, "wake-open")).toMatch(/opacity: 1;\s*clip-path: inset\(0\);/); + expect(end(wakeFrames, "wake-shut")).toContain("opacity: 0;"); + 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);"); + }); +}); + +describe("glances", () => { + test("two bots look at each other when one writes to the other", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + const writer = fake.addBot(crew.id, "Writer"); + // Scout sits above Writer on screen. + const rows: Record = { [scout.id]: 100, [writer.id]: 200 }; + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + const top = rows[this.getAttribute("data-bot") ?? ""]; + return top === undefined ? new DOMRect(0, 0, 0, 0) : new DOMRect(20, top, 32, 32); + }); + renderApp(fake); + await crewOpened("Ops"); + const mascots = (botId: BotId) => [...document.querySelectorAll(`svg[data-bot="${botId}"]`)]; + + const message: Message = { + id: "msg_glance" as MessageId, + crewId: crew.id, + fromKind: "bot", + fromBotId: scout.id, + toBotId: writer.id, + kind: "note", + body: "", + taskId: null, + routineId: null, + questionId: null, + attachments: [], + createdAt: Date.now(), + }; + act(() => fake.emit({ name: "message.created", params: message })); + + const looking = (botId: BotId) => mascots(botId).find((svg) => svg.hasAttribute("data-glance")); + await waitFor(() => expect(looking(writer.id)).toBeDefined()); + // Writer looks up at Scout; Scout looks down at Writer. + expect((looking(writer.id) as SVGElement).style.getPropertyValue("--glance-y")).toBe("-22px"); + expect((looking(scout.id) as SVGElement).style.getPropertyValue("--glance-y")).toBe("22px"); + await waitFor(() => expect(looking(writer.id)).toBeUndefined(), { timeout: 3000 }); + }); +}); diff --git a/app/src/features/bots/useGlance.ts b/app/src/features/bots/useGlance.ts new file mode 100644 index 00000000..039a8deb --- /dev/null +++ b/app/src/features/bots/useGlance.ts @@ -0,0 +1,70 @@ +// Bots look at each other when they talk (spec 15.3): when one bot sends +// another a message, the mascots of both glance toward each other for a +// moment, wherever the other one shows on screen (the sidebar, a crew's +// cards). A bot whose partner is nowhere on screen does not glance. + +import { type RefObject, useEffect, useState } from "react"; +import type { BotId } from "../../lib/protocol.gen"; +import { useOptionalApi } from "../../store/context"; + +/** How long a glance lasts, as in mascot-glance.css. */ +export const GLANCE_MS = 1600; + +/** How far the eyes move, in the artwork's units (it is 634 across). */ +const REACH = { x: 32, y: 22 }; + +export interface Glance { + x: number; + y: number; +} + +/** Where `from` should look to see the nearest mascot of `botId`, if any shows. */ +export function glanceToward(from: Element, botId: BotId): Glance | null { + const here = from.getBoundingClientRect(); + const center = { x: here.left + here.width / 2, y: here.top + here.height / 2 }; + let nearest: { dx: number; dy: number; distance: number } | null = null; + for (const other of document.querySelectorAll(`svg[data-bot="${botId}"]`)) { + const there = other.getBoundingClientRect(); + if (there.width === 0 || there.height === 0) continue; + const dx = there.left + there.width / 2 - center.x; + const dy = there.top + there.height / 2 - center.y; + const distance = Math.hypot(dx, dy); + if (distance >= 1 && (!nearest || distance < nearest.distance)) { + nearest = { dx, dy, distance }; + } + } + if (!nearest) return null; + return { + x: Math.round((nearest.dx / nearest.distance) * REACH.x), + y: Math.round((nearest.dy / nearest.distance) * REACH.y), + }; +} + +/** Where the mascot of `botId` (drawn in `svg`) is glancing now, if it is. */ +export function useGlance( + botId: BotId | undefined, + svg: RefObject, +): Glance | null { + const api = useOptionalApi(); + const [glance, setGlance] = useState(null); + useEffect(() => { + if (!api || !botId) return; + let timer: ReturnType | undefined; + const stop = api.subscribe((event) => { + if (event.name !== "message.created") return; + const { fromBotId, toBotId } = event.params; + if (!fromBotId || fromBotId === toBotId) return; + const partner = toBotId === botId ? fromBotId : fromBotId === botId ? toBotId : null; + const toward = partner && svg.current ? glanceToward(svg.current, partner) : null; + if (!toward) return; + setGlance(toward); + clearTimeout(timer); + timer = setTimeout(() => setGlance(null), GLANCE_MS); + }); + return () => { + stop(); + clearTimeout(timer); + }; + }, [api, botId, svg]); + return glance; +} diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index b40683f0..c3f322b0 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -194,7 +194,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto {chat.items.length === 0 && !chat.loading && !chat.error && !live && (
- +

{t.chat.view.emptyTitle(bot.name)}

{bot.role &&

{bot.role}

} diff --git a/app/src/features/crews/CrewBots.tsx b/app/src/features/crews/CrewBots.tsx index fd67bdcb..d209904d 100644 --- a/app/src/features/crews/CrewBots.tsx +++ b/app/src/features/crews/CrewBots.tsx @@ -3,9 +3,9 @@ import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; -import { ListAvatar } from "../bots/BotAvatar"; import { BotStateBadge } from "../bots/BotStateBadge"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; +import { ListAvatar } from "../bots/ListAvatar"; /** The crew's bots as cards; a card opens the bot. */ export function CrewBots({ crew, bots, onNewBot }: { crew: Crew; bots: Bot[]; onNewBot(): void }) { diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index a4f447f2..1869779f 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -13,10 +13,10 @@ import { Button } from "../../ui/Button"; import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu"; import { APP_OPENED } from "../../ui/motion"; import { AccountArea } from "../account/AccountArea"; -import { ListAvatar } from "../bots/BotAvatar"; import { BotStateBadge, stateView } from "../bots/BotStateBadge"; import { useBotActions } from "../bots/botActions"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; +import { ListAvatar } from "../bots/ListAvatar"; import { toolAction, toolTitle } from "../chat/toolNames"; import { CrewDialog } from "./CrewDialog"; import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed"; diff --git a/app/src/index.css b/app/src/index.css index 22570cea..a3ec5e07 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -8,6 +8,8 @@ @import "./mascot.css"; @import "./mascot-flame.css"; @import "./mascot-cheer.css"; +@import "./mascot-wake.css"; +@import "./mascot-glance.css"; @import "./panels.css"; @import "./sidebar.css"; @import "./dialog.css"; diff --git a/app/src/mascot-glance.css b/app/src/mascot-glance.css new file mode 100644 index 00000000..52efee68 --- /dev/null +++ b/app/src/mascot-glance.css @@ -0,0 +1,25 @@ +/* Bots look at each other when they talk (useGlance.ts, spec 15.3): the + eyes turn toward the other bot's mascot (--glance-x, --glance-y), hold + there for a moment and come back. A sleeping mascot keeps its eyes shut. + The glance ends where it started, so less motion shows nothing. */ + +@layer components { + [data-mood]:not([data-mood="sleeping"])[data-glance] .avatar-look { + animation: glance 1.6s both; + } +} + +@keyframes glance { + 0% { + transform: translate(0, 0); + animation-timing-function: cubic-bezier(0.2, 0.8, 0.3, 1); + } + 16%, + 78% { + transform: translate(var(--glance-x, 0), var(--glance-y, 0)); + animation-timing-function: ease-in-out; + } + 100% { + transform: translate(0, 0); + } +} diff --git a/app/src/mascot-wake.css b/app/src/mascot-wake.css new file mode 100644 index 00000000..e5806f9a --- /dev/null +++ b/app/src/mascot-wake.css @@ -0,0 +1,117 @@ +/* The mascot coming in (mascotMoments.ts, spec 15.3). It wakes when its bot + comes back from paused or stopped: the small ember grows up into the + flame, stretching past it and settling, while the shut eyes give way to + open ones that open halfway, close once more and open for good. A bot just + created pops in: the mascot grows from a dot, lands squashed, springs up + and settles, opening its eyes. Every frame ends at rest, so less motion + shows nothing. */ + +@layer components { + [data-mood][data-wake] .avatar-hop { + animation: wake-up 800ms both; + } + + [data-mood][data-wake] .avatar-eye { + animation: wake-open 800ms both; + } + + [data-mood][data-wake] .avatar-closed { + animation: wake-shut 800ms both; + } + + svg[data-arrive] { + transform-origin: 50% 100%; + animation: arrive-pop 700ms both; + } + + [data-mood][data-arrive] .avatar-eye { + animation: arrive-open 700ms both; + } +} + +@keyframes wake-up { + 0% { + transform: scale(0.8, 0.62); + animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1); + } + 40% { + transform: scale(0.95, 1.1); + animation-timing-function: ease-in-out; + } + 62% { + transform: scale(1.04, 0.95); + } + 80% { + transform: scale(0.99, 1.01); + } + 100% { + transform: scale(1, 1); + } +} + +/* Halfway open, a heavy blink, then wide awake. */ +@keyframes wake-open { + 0%, + 30% { + opacity: 0; + clip-path: inset(50% 0); + } + 48% { + opacity: 1; + clip-path: inset(28% 0); + } + 60% { + opacity: 1; + clip-path: inset(46% 0); + } + 78%, + 100% { + opacity: 1; + clip-path: inset(0); + } +} + +@keyframes wake-shut { + 0%, + 28% { + opacity: 1; + } + 34%, + 100% { + opacity: 0; + } +} + +@keyframes arrive-pop { + 0% { + opacity: 0; + transform: scale(0.2, 0.2); + animation-timing-function: cubic-bezier(0.2, 0.8, 0.3, 1); + } + 35% { + opacity: 1; + transform: scale(1.12, 0.88); + animation-timing-function: ease-in-out; + } + 58% { + transform: scale(0.95, 1.06); + } + 78% { + transform: scale(1.02, 0.98); + } + 100% { + opacity: 1; + transform: scale(1, 1); + } +} + +@keyframes arrive-open { + 0%, + 40% { + clip-path: inset(50% 0); + } + 60%, + 100% { + clip-path: inset(0); + } +} diff --git a/app/src/store/context.tsx b/app/src/store/context.tsx index 6a59fac1..34d297d6 100644 --- a/app/src/store/context.tsx +++ b/app/src/store/context.tsx @@ -41,6 +41,11 @@ export function useApi(): BotloftApi { return required(useContext(ApiContext), "BotloftApi"); } +/** The API where there is one: a mascot also shows outside the app (Setup). */ +export function useOptionalApi(): BotloftApi | null { + return useContext(ApiContext); +} + export function useApp(selector: (state: AppState) => T): T { return useStore(required(useContext(StoreContext), "AppStore"), selector); } diff --git a/app/vite.config.ts b/app/vite.config.ts index 043a2935..38e7f269 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 and cheer frames as text, and the + // The mascot test reads the flame and moment frames as text, and the // messages test the color tokens; other CSS stays empty. - css: { include: [/mascot-(flame|cheer)\.css/, /src\/index\.css/] }, + css: { include: [/mascot-(flame|cheer|wake|glance)\.css/, /src\/index\.css/] }, }, })); diff --git a/docs/spec.md b/docs/spec.md index 6cd23cb3..60e5373f 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, 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. +- **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. Do mesmo jeito, quando volta de pausado ou parado, acorda (`mascot-wake.css`, 0,8 s): a brasa cresce até virar chama, passa um pouco do tamanho e assenta, e os olhos fechados dão lugar aos abertos, que abrem pela metade, piscam pesado e abrem de vez. Um bot criado com o app aberto chega com um pulo na barra lateral e nos cartões da crew (0,7 s): o mascote cresce de um ponto, pousa achatado, estica e assenta, abrindo os olhos. Quando um bot manda mensagem para outro, os dois se olham (`mascot-glance.css`, 1,6 s): os olhos vão na direção do mascote do outro que estiver mais perto na tela, param um instante e voltam; se o outro não aparece na tela, ninguém olha, e um mascote dormindo continua de olhos fechados. 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 novo sobe aparecendo (240 ms, uma animação comum de opacidade e `transform`); a barra lateral fica onde está. Não usa View Transitions: no WebView2, quando uma acabava, o mascote do cabeçalho às vezes saía sem o recorte por uns 70 ms, um quadrado laranja (medido em 2026-10-04 no app instalado, gravando os quadros; sem a transição, não aconteceu). From 0295f4ac459d1af37a177659f7737310dd56eba6 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:54:22 -0300 Subject: [PATCH 2/2] fix(app): no cheer after start-up, no wake over a pop in Checked against real Claude Code: a new bot is created stopped and starts at once, so its mascot woke on top of its pop in, then cheered when it finished starting up, as if it had finished work. The mascot now cheers only when the bot leaves real work (not launching), and does not wake while it is still popping in. A new bot that starts after its pop in arrives asleep and wakes, which reads well. --- app/src/features/bots/BotAvatar.tsx | 7 ++++-- app/src/features/bots/BotHeader.tsx | 8 ++++++- app/src/features/bots/ListAvatar.tsx | 1 + app/src/features/bots/mascotMoments.ts | 27 ++++++++++++++-------- app/src/features/bots/moments.test.tsx | 31 ++++++++++++++++++++++++++ app/src/features/chat/ChatView.tsx | 8 ++++++- docs/spec.md | 2 +- 7 files changed, 70 insertions(+), 14 deletions(-) diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 595a0f7c..e668fb51 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -100,6 +100,7 @@ export function BotAvatar({ still = false, botId, arriving = false, + starting = false, }: { color: string; size?: number; @@ -111,6 +112,8 @@ export function BotAvatar({ 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, ""); @@ -126,7 +129,7 @@ export function BotAvatar({ [color], ); const svg = useRef(null); - const moment = useMoment(mood); + const moment = useMoment(mood, starting); const arrive = useArrive(arriving); const glance = useGlance(mood && !still ? botId : undefined, svg); const style = mood @@ -148,7 +151,7 @@ export function BotAvatar({ data-still={still || undefined} data-bot={botId} data-cheer={moment === "cheer" || undefined} - data-wake={moment === "wake" || undefined} + data-wake={(moment === "wake" && !arrive) || undefined} data-arrive={(mood && arrive) || undefined} data-glance={glance ? "" : undefined} style={style} diff --git a/app/src/features/bots/BotHeader.tsx b/app/src/features/bots/BotHeader.tsx index 39777c40..821c337e 100644 --- a/app/src/features/bots/BotHeader.tsx +++ b/app/src/features/bots/BotHeader.tsx @@ -77,7 +77,13 @@ export function BotHeader({ return ( // In a narrow window the handle gives way first, then the button labels.
- +

{bot.name}

diff --git a/app/src/features/bots/ListAvatar.tsx b/app/src/features/bots/ListAvatar.tsx index b935fa24..7e50a99e 100644 --- a/app/src/features/bots/ListAvatar.tsx +++ b/app/src/features/bots/ListAvatar.tsx @@ -39,6 +39,7 @@ export function ListAvatar({ still={still} botId={bot.id} arriving={arriving} + starting={bot.state === "launching"} /> ); } diff --git a/app/src/features/bots/mascotMoments.ts b/app/src/features/bots/mascotMoments.ts index 72a18723..269aa247 100644 --- a/app/src/features/bots/mascotMoments.ts +++ b/app/src/features/bots/mascotMoments.ts @@ -16,10 +16,19 @@ export const MOMENT_MS: Record = { cheer: 1200, wake: 800 }; /** How long a new bot's mascot pops in, as in mascot-wake.css. */ export const ARRIVE_MS = 700; -/** The moment a change of mood calls for, if any. */ -function momentOf(last: Mood | undefined, mood: Mood | undefined): Moment | null { - if (last === "working" && mood === "idle") return "cheer"; - if (last === "sleeping" && mood !== undefined && mood !== "sleeping") return "wake"; +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; } @@ -27,12 +36,12 @@ function momentOf(last: Mood | undefined, mood: Mood | undefined): Moment | 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): Moment | null { - const [last, setLast] = useState(mood); +export function useMoment(mood: Mood | undefined, starting = false): Moment | null { + const [last, setLast] = useState({ mood, starting }); const [moment, setMoment] = useState(null); - if (mood !== last) { - setLast(mood); - setMoment(momentOf(last, mood)); + if (mood !== last.mood || starting !== last.starting) { + setLast({ mood, starting }); + if (mood !== last.mood) setMoment(momentOf(last, mood)); } useEffect(() => { if (!moment) return; diff --git a/app/src/features/bots/moments.test.tsx b/app/src/features/bots/moments.test.tsx index fc70b73f..34182f71 100644 --- a/app/src/features/bots/moments.test.tsx +++ b/app/src/features/bots/moments.test.tsx @@ -32,6 +32,37 @@ describe("moments", () => { expect(svgOf(container).hasAttribute("data-wake")).toBe(false); }); + test("does not cheer when it only finished starting up", () => { + const { container, rerender } = render(); + rerender(); + expect(svgOf(container).hasAttribute("data-cheer")).toBe(false); + + // Started, then worked: finishing that work is worth a cheer. + rerender(); + rerender(); + rerender(); + expect(svgOf(container).hasAttribute("data-cheer")).toBe(true); + }); + + test("a new bot that starts up while it pops in does not wake on top of it", () => { + vi.useFakeTimers(); + vi.setSystemTime(APP_OPENED + 60_000); + const created = { id: "bot_new" as BotId, color: "#9be564", paused: false }; + const createdAt = APP_OPENED + 59_000; + const { container, rerender } = render( + , + ); + rerender( + , + ); + expect(svgOf(container).hasAttribute("data-arrive")).toBe(true); + expect(svgOf(container).hasAttribute("data-wake")).toBe(false); + }); + test("a bot created a moment ago pops in once; an older one does not", () => { vi.useFakeTimers(); vi.setSystemTime(APP_OPENED + 60_000); diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index c3f322b0..1066c772 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -194,7 +194,13 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto {chat.items.length === 0 && !chat.loading && !chat.error && !live && (
- +

{t.chat.view.emptyTitle(bot.name)}

{bot.role &&

{bot.role}

} diff --git a/docs/spec.md b/docs/spec.md index 60e5373f..fda2b397 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, 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. Do mesmo jeito, quando volta de pausado ou parado, acorda (`mascot-wake.css`, 0,8 s): a brasa cresce até virar chama, passa um pouco do tamanho e assenta, e os olhos fechados dão lugar aos abertos, que abrem pela metade, piscam pesado e abrem de vez. Um bot criado com o app aberto chega com um pulo na barra lateral e nos cartões da crew (0,7 s): o mascote cresce de um ponto, pousa achatado, estica e assenta, abrindo os olhos. Quando um bot manda mensagem para outro, os dois se olham (`mascot-glance.css`, 1,6 s): os olhos vão na direção do mascote do outro que estiver mais perto na tela, param um instante e voltam; se o outro não aparece na tela, ninguém olha, e um mascote dormindo continua de olhos fechados. 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, nem um que só terminou de iniciar (`launching`). Do mesmo jeito, quando volta de pausado ou parado, acorda (`mascot-wake.css`, 0,8 s): a brasa cresce até virar chama, passa um pouco do tamanho e assenta, e os olhos fechados dão lugar aos abertos, que abrem pela metade, piscam pesado e abrem de vez. Um bot criado com o app aberto chega com um pulo na barra lateral e nos cartões da crew (0,7 s): o mascote cresce de um ponto, pousa achatado, estica e assenta, abrindo os olhos; como o bot nasce parado, ele chega dormindo e acorda quando começa a iniciar, e se isso acontece ainda durante a chegada, não acorda por cima dela. Quando um bot manda mensagem para outro, os dois se olham (`mascot-glance.css`, 1,6 s): os olhos vão na direção do mascote do outro que estiver mais perto na tela, param um instante e voltam; se o outro não aparece na tela, ninguém olha, e um mascote dormindo continua de olhos fechados. 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 novo sobe aparecendo (240 ms, uma animação comum de opacidade e `transform`); a barra lateral fica onde está. Não usa View Transitions: no WebView2, quando uma acabava, o mascote do cabeçalho às vezes saía sem o recorte por uns 70 ms, um quadrado laranja (medido em 2026-10-04 no app instalado, gravando os quadros; sem a transição, não aconteceu).