diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 08037884..e668fb51 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,9 @@ export function BotAvatar({ framed = false, mood, still = false, + botId, + arriving = false, + starting = false, }: { color: string; size?: number; @@ -120,6 +108,12 @@ 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; + /** 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, ""); @@ -134,11 +128,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, starting); + const arrive = useArrive(arriving); + const glance = useGlance(mood && !still ? botId : undefined, svg); + const style = mood + ? ({ + "--blink-delay": blinkDelay(color), + ...(glance && { "--glance-x": `${glance.x}px`, "--glance-y": `${glance.y}px` }), + } as CSSProperties) + : undefined; const box = framed ? [CENTER.x - 500, CENTER.y - 470, 1000, 1000] : VIEW_BOX; return ( @@ -259,24 +266,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..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 new file mode 100644 index 00000000..7e50a99e --- /dev/null +++ b/app/src/features/bots/ListAvatar.tsx @@ -0,0 +1,48 @@ +// A bot's mascot in a list (the sidebar, a crew's cards), with the bot's +// mood, its glances and, for a bot just created, its pop in (BotAvatar.tsx). + +import { useState } from "react"; +import type { Bot } from "../../lib/protocol.gen"; +import { APP_OPENED } from "../../ui/motion"; +import { BotAvatar, moodOf } from "./BotAvatar"; + +/** + * A bot's mascot in a list (the sidebar, a crew's cards): it moves while + * the bot is awake, idle too, since the owner reads an idle bot by its + * slow flame and glances. A sleeping one keeps its look but does not + * move: a morphing flame repaints every frame, and a stopped bot has + * nothing to show. + */ +export function ListAvatar({ + bot, + crewPaused, + size, +}: { + bot: Pick & 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..269aa247 --- /dev/null +++ b/app/src/features/bots/mascotMoments.ts @@ -0,0 +1,63 @@ +// The mascot's moments (spec 15.3): short gestures it makes once, on top of +// its mood, when something happens to its bot. It cheers when the bot +// finishes what it was doing (mascot-cheer.css), wakes when it comes back +// from paused or stopped, and pops in when the bot was just created +// (mascot-wake.css). Each lasts as long as its animation, then the mood +// takes over again. + +import { useEffect, useState } from "react"; +import type { Mood } from "./BotAvatar"; + +export type Moment = "cheer" | "wake"; + +/** How long each moment lasts, as in its CSS. */ +export const MOMENT_MS: Record = { cheer: 1200, wake: 800 }; + +/** How long a new bot's mascot pops in, as in mascot-wake.css. */ +export const ARRIVE_MS = 700; + +interface Seen { + mood: Mood | undefined; + /** The bot was starting up (launching), which also shows as working. */ + starting: boolean; +} + +/** + * The moment a change of mood calls for, if any. Only a bot that finished + * real work cheers: one that just finished starting up does not. + */ +function momentOf(last: Seen, mood: Mood | undefined): Moment | null { + if (last.mood === "working" && !last.starting && mood === "idle") return "cheer"; + if (last.mood === "sleeping" && mood !== undefined && mood !== "sleeping") return "wake"; + return null; +} + +/** + * The moment the mascot is in now: from the change of mood that calls for + * it, for as long as it lasts. One that shows up in a mood makes none. + */ +export function useMoment(mood: Mood | undefined, starting = false): Moment | null { + const [last, setLast] = useState({ mood, starting }); + const [moment, setMoment] = useState(null); + if (mood !== last.mood || starting !== last.starting) { + setLast({ mood, starting }); + if (mood !== last.mood) setMoment(momentOf(last, mood)); + } + useEffect(() => { + if (!moment) return; + const timer = setTimeout(() => setMoment(null), MOMENT_MS[moment]); + return () => clearTimeout(timer); + }, [moment]); + return moment; +} + +/** Whether the mascot is still popping in, when it showed up `arriving`. */ +export function useArrive(arriving: boolean): boolean { + const [now, setNow] = useState(arriving); + useEffect(() => { + if (!now) return; + const timer = setTimeout(() => setNow(false), ARRIVE_MS); + return () => clearTimeout(timer); + }, [now]); + return now; +} diff --git a/app/src/features/bots/moments.test.tsx b/app/src/features/bots/moments.test.tsx new file mode 100644 index 00000000..34182f71 --- /dev/null +++ b/app/src/features/bots/moments.test.tsx @@ -0,0 +1,143 @@ +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("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); + 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..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/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..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. 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).