diff --git a/CLAUDE.md b/CLAUDE.md index 9b6a17cd..8d3adf94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ pnpm check # tsc --noEmit + biome check + vitest run pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release\bundle\nsis pnpm bundle:setup # tela de instalação com esse NSIS dentro (spec 15.7), em target\release\bundle\setup pnpm local # compila em release e troca o app e o botloftd do Botloft instalado, sem instalador; `pnpm local --restore` volta o anterior +pnpm reel # grava o carrossel dos mascotes (?reel, com o pnpm dev rodando) em .dev/reel/reel.mp4; --blur, --seconds, --size, --url ``` O `pnpm local` usa os dados reais (`%LOCALAPPDATA%\Botloft`), não os de dev. As migrations novas rodam no banco real, e um daemon mais antigo deixa de abrir esse banco. O atualizador continua oferecendo releases de versão maior. diff --git a/app/package.json b/app/package.json index 30a722bd..a2e5ce9c 100644 --- a/app/package.json +++ b/app/package.json @@ -13,6 +13,7 @@ "bundle": "node scripts/sidecar.mjs && tauri build --config src-tauri/tauri.bundle.conf.json", "bundle:setup": "node scripts/setup.mjs", "local": "node scripts/local.mjs", + "reel": "node scripts/reel.mjs", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "lint": "biome check .", "format": "biome check --write .", diff --git a/app/scripts/reel.mjs b/app/scripts/reel.mjs new file mode 100644 index 00000000..f674d12c --- /dev/null +++ b/app/scripts/reel.mjs @@ -0,0 +1,235 @@ +// Records the mascot reel (src/dev/Reel.tsx) to a video: opens the page in +// a headless Edge or Chrome with its clock stopped (reelClock.js), moves it +// a sixtieth of a second at a time and takes a picture of each frame over the +// DevTools protocol, then has ffmpeg (on the PATH) join them at 60 frames a +// second. The frames come out the same however slow the computer is. Run +// `pnpm dev` first. +// +// pnpm reel 12 s at 1920x1080 into .dev/reel/reel.mp4 +// pnpm reel --seconds 30 --size 1080x1920 --beat 2600 +// pnpm reel --blur blends each frame with its neighbors +// pnpm reel --url http://localhost:1456 --out clip.mp4 +// +// REEL_BROWSER points to another Chromium browser. + +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const app = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function option(name, fallback) { + const at = process.argv.indexOf(`--${name}`); + return at === -1 ? fallback : (process.argv[at + 1] ?? fallback); +} + +const seconds = Number(option("seconds", "12")); +const [width, height] = option("size", "1920x1080").split("x").map(Number); +const base = option("url", "http://localhost:1420"); +const beat = option("beat", ""); +const blur = process.argv.includes("--blur"); +const out = resolve(option("out", join(app, "..", ".dev", "reel", "reel.mp4"))); +const url = `${base.replace(/\/$/, "")}/?reel${beat ? `&beat=${beat}` : ""}`; + +const BROWSERS = { + win32: [ + "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/Google/Chrome/Application/chrome.exe", + ], + darwin: [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ], + linux: ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"], +}; +const browserPath = + process.env.REEL_BROWSER ?? (BROWSERS[process.platform] ?? []).find((path) => existsSync(path)); +if (!browserPath) { + throw new Error("no Chromium browser found: set REEL_BROWSER"); +} + +const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); + +/** Waits until `read` returns something, for up to 15 s. */ +async function until(read) { + for (let tries = 0; tries < 150; tries++) { + try { + const value = await read(); + if (value) return value; + } catch {} + await sleep(100); + } + throw new Error("the browser did not start"); +} + +/** + * A picture of the page as it is. Now and then the browser never answers + * one; a fresh paint (the clock's corner pixel, stepped by nothing) and + * another try get it going again. + */ +async function picture(cdp) { + for (let tries = 1; ; tries++) { + try { + const shot = { format: "jpeg", quality: 95 }; + return (await cdp.send("Page.captureScreenshot", shot, 5_000)).data; + } catch (error) { + if (tries === 5) throw error; + await cdp.send("Runtime.evaluate", { expression: "__reelStep(0)", awaitPromise: true }); + } + } +} + +/** A small DevTools protocol client over the page's WebSocket. */ +function devtools(socket) { + let next = 0; + const waiting = new Map(); + const listeners = new Map(); + socket.addEventListener("message", ({ data }) => { + const message = JSON.parse(data); + if (message.id !== undefined) { + const call = waiting.get(message.id); + waiting.delete(message.id); + if (message.error) call?.fail(new Error(message.error.message)); + else call?.done(message.result); + } else { + for (const listen of listeners.get(message.method) ?? []) listen(message.params); + } + }); + return { + send(method, params = {}, wait = 60_000) { + const id = ++next; + socket.send(JSON.stringify({ id, method, params })); + // A call the browser never answers fails instead of hanging the run. + return new Promise((done, fail) => { + const timer = setTimeout(() => fail(new Error(`${method} got no answer`)), wait); + waiting.set(id, { + done: (result) => { + clearTimeout(timer); + done(result); + }, + fail: (error) => { + clearTimeout(timer); + fail(error); + }, + }); + }); + }, + on(method, listen) { + listeners.set(method, [...(listeners.get(method) ?? []), listen]); + }, + once(method) { + return new Promise((done) => this.on(method, done)); + }, + }; +} + +const work = mkdtempSync(join(tmpdir(), "botloft-reel-")); +const profile = join(work, "profile"); +const frames = join(work, "frames"); +mkdirSync(frames, { recursive: true }); + +const browser = spawn( + browserPath, + [ + "--headless=new", + "--remote-debugging-port=0", + `--user-data-dir=${profile}`, + `--window-size=${width},${height}`, + "--force-device-scale-factor=1", + // Windows takes the headless window for one covered by others and stops + // painting it after a moment. + "--disable-features=CalculateNativeWinOcclusion", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--hide-scrollbars", + "--mute-audio", + "--no-first-run", + "about:blank", + ], + { stdio: "ignore" }, +); + +try { + const port = await until( + () => readFileSync(join(profile, "DevToolsActivePort"), "utf8").split("\n")[0], + ); + const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + const page = targets.find((target) => target.type === "page"); + const socket = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((done) => socket.addEventListener("open", done)); + const cdp = devtools(socket); + + await cdp.send("Page.enable"); + // A page Windows thinks is out of sight paints about once a second. + await cdp.send("Page.bringToFront"); + await cdp.send("Emulation.setFocusEmulationEnabled", { enabled: true }); + await cdp.send("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: 1, + mobile: false, + }); + // The page's clock stands still from the start (reelClock.js), on the + // reel's own dark rather than the app's light background. + const clock = readFileSync(join(app, "scripts", "reelClock.js"), "utf8"); + const dark = + "const sheet = new CSSStyleSheet();" + + "sheet.replaceSync('html, body { background: #050505 !important; }');" + + "document.adoptedStyleSheets = [sheet];"; + await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `${clock}\n${dark}` }); + const loaded = cdp.once("Page.loadEventFired"); + await cdp.send("Page.navigate", { url }); + await loaded; + await until(async () => { + const { result } = await cdp.send("Runtime.evaluate", { + expression: "document.querySelector('.reel-place') !== null", + }); + return result.value; + }); + + const total = Math.round(seconds * 60); + for (let index = 0; index < total; index++) { + if (index > 0) { + await cdp.send("Runtime.evaluate", { + expression: `__reelStep(${1000 / 60})`, + awaitPromise: true, + }); + } + const data = await picture(cdp); + const name = `frame_${String(index + 1).padStart(5, "0")}.jpg`; + writeFileSync(join(frames, name), Buffer.from(data, "base64")); + if ((index + 1) % 60 === 0) { + process.stdout.write(`${(index + 1) / 60} s of ${seconds}\r`); + } + } + await cdp.send("Browser.close").catch(() => {}); + + mkdirSync(dirname(out), { recursive: true }); + const filters = [...(blur ? ["tmix=frames=3:weights=1 2 1"] : []), "format=yuv420p"]; + execFileSync( + "ffmpeg", + [ + "-y", + "-v", + "error", + ...["-framerate", "60", "-i", join(frames, "frame_%05d.jpg")], + ...["-vf", filters.join(","), "-c:v", "libx264", "-crf", "14", "-preset", "slow"], + ...["-movflags", "+faststart", out], + ], + { stdio: "inherit" }, + ); + console.log(out); +} finally { + // The browser runs as a tree of processes; on Windows killing the first + // one leaves the rest holding the profile folder. + if (process.platform === "win32") { + try { + execFileSync("taskkill", ["/pid", String(browser.pid), "/t", "/f"], { stdio: "ignore" }); + } catch {} + } + browser.kill(); + await sleep(300); + rmSync(work, { recursive: true, force: true }); +} diff --git a/app/scripts/reelClock.js b/app/scripts/reelClock.js new file mode 100644 index 00000000..d27659ca --- /dev/null +++ b/app/scripts/reelClock.js @@ -0,0 +1,90 @@ +// Runs in the reel page before anything else (scripts/reel.mjs reads it as +// text): the page's clock stands still until the recorder moves it, one +// frame at a time. Timers, animation frames, Date.now and performance.now +// follow it, and so do CSS animations and transitions, held paused at the +// time they have played to. The page then looks the same at a given time on +// any computer, however long each frame takes to capture. + +(() => { + const real = { + setTimeout: window.setTimeout.bind(window), + requestAnimationFrame: window.requestAnimationFrame.bind(window), + }; + const epoch = Date.now(); + let now = 0; + let nextId = 1; + const timers = new Map(); + const frames = new Map(); + + const delay = (ms) => Math.max(0, Number(ms) || 0); + window.setTimeout = (run, ms, ...args) => { + const id = nextId++; + timers.set(id, { at: now + delay(ms), every: 0, run: () => run(...args) }); + return id; + }; + window.setInterval = (run, ms, ...args) => { + const id = nextId++; + const every = Math.max(1, delay(ms)); + timers.set(id, { at: now + every, every, run: () => run(...args) }); + return id; + }; + window.clearTimeout = (id) => timers.delete(id); + window.clearInterval = (id) => timers.delete(id); + window.requestAnimationFrame = (run) => { + const id = nextId++; + frames.set(id, run); + return id; + }; + window.cancelAnimationFrame = (id) => frames.delete(id); + performance.now = () => now; + Date.now = () => epoch + now; + + /** When each animation started, on this clock. */ + const started = new WeakMap(); + function hold() { + for (const animation of document.getAnimations()) { + if (!started.has(animation)) started.set(animation, now); + animation.pause(); + animation.currentTime = now - started.get(animation); + } + } + + /** Lets the page's own work (React's renders) run in real time. */ + const settle = () => new Promise((done) => real.setTimeout(done, 0)); + + /** Moves the clock `ms` on, firing what falls due on the way, in order. */ + window.__reelStep = async (ms) => { + const until = now + ms; + for (;;) { + let next; + for (const [id, timer] of timers) { + if (timer.at <= until && (!next || timer.at < next.timer.at)) next = { id, timer }; + } + if (!next) break; + now = next.timer.at; + if (next.timer.every) next.timer.at += next.timer.every; + else timers.delete(next.id); + try { + next.timer.run(); + } catch (error) { + console.error(error); + } + await settle(); + } + now = until; + const due = [...frames.values()]; + frames.clear(); + for (const run of due) run(now); + await settle(); + await settle(); + hold(); + // A picture waits for the next painted frame, and a step where nothing + // moved paints none: a pixel in the corner, too dark to see, flips + // shade every step so there always is one. + if (!tick.isConnected) document.body.append(tick); + tick.style.background = tick.style.background === "rgb(5, 5, 5)" ? "#060606" : "#050505"; + await new Promise((done) => real.requestAnimationFrame(() => done())); + }; + const tick = document.createElement("div"); + tick.style.cssText = "position: fixed; left: 0; top: 0; width: 1px; height: 1px;"; +})(); diff --git a/app/src/dev/Reel.tsx b/app/src/dev/Reel.tsx new file mode 100644 index 00000000..0823e175 --- /dev/null +++ b/app/src/dev/Reel.tsx @@ -0,0 +1,89 @@ +// A page to record the mascots from (`?reel`, dev only): a row of bots in +// many colors passing by, as in a carousel. Every beat the row slides one +// place; the bot that comes to the middle lands, works for a moment and +// cheers when it finishes, with the same moods as in the app (BotAvatar). +// The others idle, blinking and glancing each in its own time. +// `scripts/reel.mjs` records it to a video. + +import { type CSSProperties, useEffect, useState } from "react"; +import { BotAvatar, CHEER_MS, type Mood } from "../features/bots/BotAvatar"; +import "./reel.css"; + +const COLORS = [ + "#ff7a59", + "#5ec8ff", + "#8b6bff", + "#4ade5c", + "#ff5fa8", + "#3fe0c5", + "#ffcf3f", + "#ff4d4d", +]; + +const params = new URLSearchParams(window.location.search); +/** Milliseconds between two slides (`?reel&beat=3000`). */ +const BEAT = Number(params.get("beat")) || 3000; +/** When the bot in the middle starts working, once it has landed. */ +const WORK_FROM = 450; +/** When it finishes, leaving its cheer time before the next slide. */ +const WORK_UNTIL = Math.max(WORK_FROM + 400, BEAT - CHEER_MS - 250); +/** How many places show on each side of the middle. */ +const REACH = 3; + +type Phase = "arrive" | "work" | "done"; + +/** Where a place sits, in gaps from the middle: the neighbors stand closer together. */ +function across(offset: number): number { + if (offset === 0) return 0; + return Math.sign(offset) * (1 + (Math.abs(offset) - 1) * 0.72); +} + +export function Reel() { + const [step, setStep] = useState(0); + const [phase, setPhase] = useState("arrive"); + const [size] = useState(() => Math.round(window.innerHeight * 0.36)); + + useEffect(() => { + document.documentElement.dataset.theme = "dark"; + }, []); + + useEffect(() => { + setPhase("arrive"); + const timers = [ + setTimeout(() => setPhase("work"), WORK_FROM), + setTimeout(() => setPhase("done"), WORK_UNTIL), + setTimeout(() => setStep(step + 1), BEAT), + ]; + return () => { + for (const timer of timers) clearTimeout(timer); + }; + }, [step]); + + const places: number[] = []; + for (let place = step - REACH - 1; place <= step + REACH + 1; place++) { + places.push(place); + } + return ( +
+ {places.map((place) => { + const offset = place - step; + const center = offset === 0; + const mood: Mood = center && phase === "work" ? "working" : "idle"; + const color = COLORS[((place % COLORS.length) + COLORS.length) % COLORS.length]; + return ( +
REACH || undefined} + style={{ "--across": across(offset) } as CSSProperties} + > +
+ +
+
+ ); + })} +
+ ); +} diff --git a/app/src/dev/reel.css b/app/src/dev/reel.css new file mode 100644 index 00000000..855f386c --- /dev/null +++ b/app/src/dev/reel.css @@ -0,0 +1,55 @@ +/* The mascot reel (Reel.tsx, dev only). The row slides with an ease that + starts and stops softly; the bot coming to the middle grows into it and + lands squashed, then springs back. */ + +.reel { + --gap: calc(var(--hero) * 1.1); + position: fixed; + inset: 0; + overflow: hidden; + background: #050505; +} + +.reel-place { + position: absolute; + left: 50%; + top: 54%; + width: var(--hero); + height: var(--hero); + margin: calc(var(--hero) / -2); + transform: translateX(calc(var(--across) * var(--gap))) scale(0.46); + transition: + transform 280ms cubic-bezier(0.65, 0, 0.25, 1), + opacity 280ms ease-out; +} + +.reel-place[data-center] { + transform: translateX(0) scale(1); +} + +.reel-place[data-out] { + opacity: 0; +} + +.reel-land { + transform-origin: 50% 100%; +} + +.reel-place[data-center] .reel-land { + animation: reel-land 560ms 180ms both; +} + +@keyframes reel-land { + 0% { + transform: scale(1, 1); + } + 30% { + transform: scale(1.12, 0.88); + } + 62% { + transform: scale(0.96, 1.05); + } + 100% { + transform: scale(1, 1); + } +} diff --git a/app/src/dev/reel.test.tsx b/app/src/dev/reel.test.tsx new file mode 100644 index 00000000..bbf6cbb5 --- /dev/null +++ b/app/src/dev/reel.test.tsx @@ -0,0 +1,29 @@ +import { act, cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { Reel } from "./Reel"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +test("the bot in the middle lands, works, cheers, then the row moves on", () => { + const { container } = render(); + const middle = () => container.querySelector("[data-center] svg") as SVGElement; + const color = () => middle().querySelector("rect")?.getAttribute("fill"); + const first = color(); + expect(container.querySelectorAll(".reel-place")).toHaveLength(9); + expect(middle().dataset.mood).toBe("idle"); + + act(() => vi.advanceTimersByTime(500)); + expect(middle().dataset.mood).toBe("working"); + + act(() => vi.advanceTimersByTime(1100)); + expect(middle().dataset.mood).toBe("idle"); + expect(middle().hasAttribute("data-cheer")).toBe(true); + + act(() => vi.advanceTimersByTime(1400)); + expect(color()).not.toBe(first); + expect(middle().dataset.mood).toBe("idle"); +}); diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 665ad485..08037884 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -12,10 +12,11 @@ // gently when idle, glancing around now and then, and wildly, throwing // embers, while it works; it hops // while it waits for the owner, burns low with heavy eyes when tired and -// sleeps with its eyes shut while paused. Without one it stays still, as in +// sleeps with its eyes shut while paused. When it finishes what it was +// doing, it cheers once (mascot-cheer.css). Without one it stays still, as in // the chat history. -import { type CSSProperties, useId, useMemo } from "react"; +import { type CSSProperties, useEffect, useId, useMemo, useState } from "react"; import type { Bot } from "../../lib/protocol.gen"; import { BODY, @@ -55,6 +56,28 @@ export function moodOf(bot: Pick, crewPaused = false): } } +/** How long the mascot cheers when its bot finishes, as in mascot-cheer.css. */ +export const CHEER_MS = 1200; + +/** + * Whether the mascot cheers now: from the moment its mood goes from working + * to idle, for CHEER_MS. One that shows up idle does not. + */ +function useCheer(mood: Mood | undefined): boolean { + const [last, setLast] = useState(mood); + const [cheer, setCheer] = useState(false); + if (mood !== last) { + setLast(mood); + setCheer(last === "working" && mood === "idle"); + } + useEffect(() => { + if (!cheer) return; + const timer = setTimeout(() => setCheer(false), CHEER_MS); + return () => clearTimeout(timer); + }, [cheer]); + return cheer; +} + /** A delay from the color, so a crew's mascots do not blink together. */ function blinkDelay(color: string): string { let hash = 0; @@ -72,6 +95,18 @@ function glintsOn(eye: Ellipse): Ellipse[] { const rotate = ({ x, y, turn }: Ellipse) => `rotate(${turn} ${x} ${y})`; +/** + * How far the face leans, in degrees: the line through the two eyes, the + * right one sitting higher. Shut and smiling eyes lean with it. + */ +function tiltOf(eyes: readonly Ellipse[]): number { + const [left, right] = eyes; + if (!left || !right) return 0; + return (Math.atan2(right.y - left.y, right.x - left.x) * 180) / Math.PI; +} + +const TILT = tiltOf(EYES); + export function BotAvatar({ color, size = 28, @@ -99,6 +134,7 @@ export function BotAvatar({ }), [color], ); + const cheer = useCheer(mood); const style = mood ? ({ "--blink-delay": blinkDelay(color) } as CSSProperties) : undefined; const box = framed ? [CENTER.x - 500, CENTER.y - 470, 1000, 1000] : VIEW_BOX; return ( @@ -110,6 +146,7 @@ export function BotAvatar({ className={`shrink-0 overflow-visible ${framed ? "rounded-lg bg-[#0b0b0b]" : ""}`} data-mood={mood} data-still={still || undefined} + data-cheer={cheer || undefined} style={style} > @@ -175,7 +212,10 @@ export function BotAvatar({ ); } -/** One eye: open with its glints, or shut into a soft arc while asleep. */ +/** + * One eye: open with its glints, shut into a soft arc while asleep, or + * bent up into a smile while the mascot cheers. + */ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string }) { const { x, y, rx, ry } = eye; return ( @@ -196,14 +236,26 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string ))} - + {/* The arcs lean in a group of their own: the smile's CSS transform + would replace a transform set on the path itself. */} + + + + ); } diff --git a/app/src/features/bots/avatar.test.tsx b/app/src/features/bots/avatar.test.tsx index cc520e95..1466975c 100644 --- a/app/src/features/bots/avatar.test.tsx +++ b/app/src/features/bots/avatar.test.tsx @@ -1,7 +1,8 @@ -import { cleanup, render } from "@testing-library/react"; -import { afterEach, describe, expect, test } from "vitest"; +import { act, cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import cheerFrames from "../../mascot-cheer.css?raw"; import flameFrames from "../../mascot-flame.css?raw"; -import { BOTLOFT_COLOR, BotAvatar, ListAvatar } from "./BotAvatar"; +import { BOTLOFT_COLOR, BotAvatar, CHEER_MS, ListAvatar } from "./BotAvatar"; import { BotStateBadge } from "./BotStateBadge"; import { BODY, DRAWN_IN, OUTLINE, SHADES } from "./mascotArt"; import { retint, toHex, toHsl } from "./retint"; @@ -98,6 +99,48 @@ describe("mascot", () => { expect(svg()?.dataset.mood).toBeUndefined(); }); + test("cheers once when its bot finishes, not when it shows up idle", () => { + vi.useFakeTimers(); + try { + const { container, rerender } = render(); + const cheering = () => container.querySelector("svg")?.hasAttribute("data-cheer"); + expect(container.querySelectorAll(".avatar-happy")).toHaveLength(2); + expect(cheering()).toBe(false); + + rerender(); + expect(cheering()).toBe(false); + rerender(); + expect(cheering()).toBe(true); + act(() => vi.advanceTimersByTime(CHEER_MS)); + expect(cheering()).toBe(false); + + rerender(); + rerender(); + expect(cheering()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + test("shut and smiling eyes lean with the line through the open ones", () => { + const { container } = render(); + for (const arc of container.querySelectorAll(".avatar-closed, .avatar-happy")) { + const turn = arc.parentElement?.getAttribute("transform")?.match(/^rotate\((-?[\d.]+) /); + expect(Number(turn?.[1])).toBeCloseTo(-14.3, 0); + } + }); + + test("the cheer ends at rest, so less motion shows none of it", () => { + const end = (name: string) => + cheerFrames + .match(new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`))?.[1] + ?.split(/100% \{/) + .at(-1); + expect(end("cheer-hop")).toContain("transform: translateY(0) scale(1, 1);"); + expect(end("cheer-open")).toMatch(/opacity: 1;\s*clip-path: inset\(0\);/); + expect(end("cheer-smile")).toContain("opacity: 0;"); + }); + test("framed sits on the icon square without the thin edge", () => { const { container } = render(); expect(container.querySelector("svg")?.getAttribute("class")).toContain("bg-[#0b0b0b]"); diff --git a/app/src/index.css b/app/src/index.css index f3e6150e..22570cea 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -7,6 +7,7 @@ @import "./motion.css"; @import "./mascot.css"; @import "./mascot-flame.css"; +@import "./mascot-cheer.css"; @import "./panels.css"; @import "./sidebar.css"; @import "./dialog.css"; diff --git a/app/src/main.tsx b/app/src/main.tsx index 2ea635eb..8691bf92 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -31,22 +31,27 @@ async function props(): Promise<{ host: Host; connect: Connect }> { throw new Error("Botloft runs inside its desktop app"); } -props().then(({ host, connect: open }) => { - // Before the first paint, so the window never flashes at the small size - // nor moves when the owner asked for less motion. - host.setZoom(currentZoom()).catch(() => {}); - if (prefs.lessMotion.get()) { - document.documentElement.dataset.motion = "less"; - } - watchVisibility(); - // Opened at sign-in, the window stays hidden until the owner opens it. - host.launchedAtSignIn().then( - (hidden) => hidden && !document.hasFocus() && markHidden(true), - () => {}, - ); - createRoot(root).render( - - - , - ); -}); +// The mascot reel (dev/Reel.tsx), a page to record videos from. +if (import.meta.env.DEV && new URLSearchParams(window.location.search).has("reel")) { + import("./dev/Reel").then(({ Reel }) => createRoot(root).render()); +} else { + props().then(({ host, connect: open }) => { + // Before the first paint, so the window never flashes at the small size + // nor moves when the owner asked for less motion. + host.setZoom(currentZoom()).catch(() => {}); + if (prefs.lessMotion.get()) { + document.documentElement.dataset.motion = "less"; + } + watchVisibility(); + // Opened at sign-in, the window stays hidden until the owner opens it. + host.launchedAtSignIn().then( + (hidden) => hidden && !document.hasFocus() && markHidden(true), + () => {}, + ); + createRoot(root).render( + + + , + ); + }); +} diff --git a/app/src/mascot-cheer.css b/app/src/mascot-cheer.css new file mode 100644 index 00000000..bd646e6f --- /dev/null +++ b/app/src/mascot-cheer.css @@ -0,0 +1,98 @@ +/* The mascot cheers once when the bot finishes what it was doing + (BotAvatar.tsx sets data-cheer for CHEER_MS, spec 15.3): it crouches, + jumps up stretched, lands squashed and settles, while its eyes turn into + two arcs smiling up and come back open with a blink. Every frame ends at + rest, so less motion, which cuts the animation to its end, shows nothing. */ + +@layer components { + .avatar-happy { + opacity: 0; + transform-box: fill-box; + transform-origin: 50% 100%; + } + + [data-mood="idle"][data-cheer] .avatar-hop { + animation: cheer-hop 1.2s both; + } + + [data-mood="idle"][data-cheer] .avatar-eye { + animation: cheer-open 1.2s both; + } + + [data-mood="idle"][data-cheer] .avatar-happy { + animation: cheer-smile 1.2s both; + } + + [data-mood="idle"][data-cheer] .avatar-look { + animation: none; + } +} + +/* Up eases out, the fall eases in, and the landing gives a little. */ +@keyframes cheer-hop { + 0% { + transform: translateY(0) scale(1, 1); + animation-timing-function: ease-in-out; + } + 12% { + transform: translateY(0) scale(1.1, 0.9); + animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1); + } + 30% { + transform: translateY(-16%) scale(0.93, 1.08); + animation-timing-function: cubic-bezier(0.6, 0, 0.8, 0.4); + } + 46% { + transform: translateY(0) scale(1.1, 0.9); + animation-timing-function: ease-out; + } + 58% { + transform: translateY(-3%) scale(0.97, 1.03); + animation-timing-function: ease-in-out; + } + 70%, + 100% { + transform: translateY(0) scale(1, 1); + } +} + +/* The open eyes give way to the smile, then open again from the middle. */ +@keyframes cheer-open { + 0%, + 20% { + opacity: 1; + clip-path: inset(0); + } + 24%, + 84% { + opacity: 0; + clip-path: inset(50% 0); + } + 92%, + 100% { + opacity: 1; + clip-path: inset(0); + } +} + +@keyframes cheer-smile { + 0%, + 20% { + opacity: 0; + transform: scale(1, 0.4); + } + 24% { + opacity: 1; + transform: scale(1, 0.4); + } + 32%, + 82% { + opacity: 1; + transform: scale(1, 1); + } + 86%, + 100% { + opacity: 0; + transform: scale(1, 1); + } +} diff --git a/app/vite.config.ts b/app/vite.config.ts index 2c3441a1..043a2935 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -39,8 +39,8 @@ export default defineConfig(({ mode }) => ({ test: { environment: "jsdom", include: ["src/**/*.test.{ts,tsx}"], - // The mascot test reads the flame frames as text, and the messages test - // the color tokens; other CSS stays empty. - css: { include: [/mascot-flame\.css/, /src\/index\.css/] }, + // The mascot test reads the flame and cheer frames as text, and the + // messages test the color tokens; other CSS stays empty. + css: { include: [/mascot-(flame|cheer)\.css/, /src\/index\.css/] }, }, })); diff --git a/docs/spec.md b/docs/spec.md index aaba5928..f6b88dfa 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -871,7 +871,7 @@ Tamanho: a janela inteira é desenhada numa escala (zoom do webview, `setZoom`), Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animações curtas (150 a 400 ms, saída suave) em CSS, sem biblioteca (`motion.css`), e todas param quando o Windows desliga os efeitos de animação (`prefers-reduced-motion`) ou quando o dono liga "Menos animações" nas Configurações (`motion-less.css` repete as mesmas regras para `:root[data-motion="less"]`, e `reducedMotion()` olha os dois). -- **Mascote:** cada bot tem um humor que segue o estado, e a chama queima de verdade: o contorno muda de forma quadro a quadro e o sombreado acompanha (`mascot.css`, `mascot-flame.css`). Parado, a chama balança devagar e os olhos dão uma olhada para baixo e para o lado de vez em quando, como nas poses do desenho; trabalhando (`busy`, `launching`), queima forte e solta faíscas pelo alto; esperando o dono (`needs_approval`), inclina rápido e dá pulinhos; no limite de uso, queima baixo, de pálpebras pesadas que caem de vez em quando; pausado ou parado, vira uma brasa pequena e dorme, de olhos fechados em dois arcos. Os olhos piscam fechando de cima e de baixo, cada bot num ritmo, para uma equipe não piscar junta. Mexem-se os mascotes do cabeçalho e da resposta em andamento, e os da barra lateral e dos cartões da crew enquanto o bot está acordado, parado também: é pela chama devagar e pelas olhadas que o dono vê de relance que ele está livre. Nessas listas, só um bot dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro e um bot desligado não tem o que mostrar. O histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto, menos o de parado e livre (`idle`), que não aparece: a chama devagar já diz isso. +- **Mascote:** cada bot tem um humor que segue o estado, e a chama queima de verdade: o contorno muda de forma quadro a quadro e o sombreado acompanha (`mascot.css`, `mascot-flame.css`). Parado, a chama balança devagar e os olhos dão uma olhada para baixo e para o lado de vez em quando, como nas poses do desenho; trabalhando (`busy`, `launching`), queima forte e solta faíscas pelo alto; esperando o dono (`needs_approval`), inclina rápido e dá pulinhos; no limite de uso, queima baixo, de pálpebras pesadas que caem de vez em quando; pausado ou parado, vira uma brasa pequena e dorme, de olhos fechados em dois arcos, inclinados como os olhos abertos. Quando termina o que fazia (de trabalhando para parado), comemora uma vez (`mascot-cheer.css`, 1,2 s): agacha, dá um pulo que estica a chama, pousa achatando, e os olhos viram dois arcos para cima, sorrindo, até voltarem abertos numa piscada; um mascote que já aparece parado não comemora. Os olhos piscam fechando de cima e de baixo, cada bot num ritmo, para uma equipe não piscar junta. Mexem-se os mascotes do cabeçalho e da resposta em andamento, e os da barra lateral e dos cartões da crew enquanto o bot está acordado, parado também: é pela chama devagar e pelas olhadas que o dono vê de relance que ele está livre. Nessas listas, só um bot dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro e um bot desligado não tem o que mostrar. O histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto, menos o de parado e livre (`idle`), que não aparece: a chama devagar já diz isso. - **Chegadas:** o que chega enquanto o dono olha entra subindo e aparecendo: mensagens, ferramentas, pedidos, avisos e bots novos na barra lateral. O que já estava lá quando ele abriu o chat (ou o app) aparece parado, e páginas antigas carregadas em cima não se mexem. A resposta já aparece enquanto é escrita, então não anima de novo ao terminar. - **Trabalhando:** os três pontos pulam em sequência; o texto sendo escrito tem um cursor piscando no fim; a mão de "Precisa de aprovação" acena; um pedido pendente pulsa uma vez ao aparecer. O estado de um bot que sai de parado aparece suave (o que já estava na tela ao abrir fica parado), e na linha de "Trabalhando" corre um trecho aceso (`StateIcons.tsx`); com menos movimento, a linha fica inteira acesa. - **Trocar de bot ou de crew:** o painel principal some rápido e o novo sobe (View Transitions do webview); a barra lateral fica onde está.