From 3b3c59e20a5a4d51ea292a7c2d3a85c7eb19194f Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:39:46 -0300 Subject: [PATCH 1/3] feat(app): mascot cheers when its bot finishes When a bot goes from working to idle, its mascot crouches, jumps up stretched, lands squashed and smiles with its eyes bent into two arcs, then opens them again with a blink (1.2 s). A mascot that shows up idle does not cheer. The motion lives in its own file, mascot-cheer.css, and every frame ends at rest, so less motion shows nothing. --- app/src/features/bots/BotAvatar.tsx | 42 +++++++++++- app/src/features/bots/avatar.test.tsx | 41 ++++++++++- app/src/index.css | 1 + app/src/mascot-cheer.css | 98 +++++++++++++++++++++++++++ app/vite.config.ts | 6 +- docs/spec.md | 2 +- 6 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 app/src/mascot-cheer.css diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 665ad485..267f1942 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -12,10 +12,11 @@ // gently when idle, glancing around now and then, and wildly, throwing // embers, while it works; it hops // while it waits for the owner, burns low with heavy eyes when tired and -// sleeps with its eyes shut while paused. Without one it stays still, as in +// sleeps with its eyes shut while paused. When it finishes what it was +// doing, it cheers once (mascot-cheer.css). Without one it stays still, as in // the chat history. -import { type CSSProperties, useId, useMemo } from "react"; +import { type CSSProperties, useEffect, useId, useMemo, useState } from "react"; import type { Bot } from "../../lib/protocol.gen"; import { BODY, @@ -55,6 +56,28 @@ export function moodOf(bot: Pick, crewPaused = false): } } +/** How long the mascot cheers when its bot finishes, as in mascot-cheer.css. */ +export const CHEER_MS = 1200; + +/** + * Whether the mascot cheers now: from the moment its mood goes from working + * to idle, for CHEER_MS. One that shows up idle does not. + */ +function useCheer(mood: Mood | undefined): boolean { + const [last, setLast] = useState(mood); + const [cheer, setCheer] = useState(false); + if (mood !== last) { + setLast(mood); + setCheer(last === "working" && mood === "idle"); + } + useEffect(() => { + if (!cheer) return; + const timer = setTimeout(() => setCheer(false), CHEER_MS); + return () => clearTimeout(timer); + }, [cheer]); + return cheer; +} + /** A delay from the color, so a crew's mascots do not blink together. */ function blinkDelay(color: string): string { let hash = 0; @@ -99,6 +122,7 @@ export function BotAvatar({ }), [color], ); + const cheer = useCheer(mood); const style = mood ? ({ "--blink-delay": blinkDelay(color) } as CSSProperties) : undefined; const box = framed ? [CENTER.x - 500, CENTER.y - 470, 1000, 1000] : VIEW_BOX; return ( @@ -110,6 +134,7 @@ export function BotAvatar({ className={`shrink-0 overflow-visible ${framed ? "rounded-lg bg-[#0b0b0b]" : ""}`} data-mood={mood} data-still={still || undefined} + data-cheer={cheer || undefined} style={style} > @@ -175,7 +200,10 @@ export function BotAvatar({ ); } -/** One eye: open with its glints, or shut into a soft arc while asleep. */ +/** + * One eye: open with its glints, shut into a soft arc while asleep, or + * bent up into a smile while the mascot cheers. + */ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string }) { const { x, y, rx, ry } = eye; return ( @@ -204,6 +232,14 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string strokeWidth={22} strokeLinecap="round" /> + ); } diff --git a/app/src/features/bots/avatar.test.tsx b/app/src/features/bots/avatar.test.tsx index cc520e95..cbd327cd 100644 --- a/app/src/features/bots/avatar.test.tsx +++ b/app/src/features/bots/avatar.test.tsx @@ -1,7 +1,8 @@ -import { cleanup, render } from "@testing-library/react"; -import { afterEach, describe, expect, test } from "vitest"; +import { act, cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import cheerFrames from "../../mascot-cheer.css?raw"; import flameFrames from "../../mascot-flame.css?raw"; -import { BOTLOFT_COLOR, BotAvatar, ListAvatar } from "./BotAvatar"; +import { BOTLOFT_COLOR, BotAvatar, CHEER_MS, ListAvatar } from "./BotAvatar"; import { BotStateBadge } from "./BotStateBadge"; import { BODY, DRAWN_IN, OUTLINE, SHADES } from "./mascotArt"; import { retint, toHex, toHsl } from "./retint"; @@ -98,6 +99,40 @@ describe("mascot", () => { expect(svg()?.dataset.mood).toBeUndefined(); }); + test("cheers once when its bot finishes, not when it shows up idle", () => { + vi.useFakeTimers(); + try { + const { container, rerender } = render(); + const cheering = () => container.querySelector("svg")?.hasAttribute("data-cheer"); + expect(container.querySelectorAll(".avatar-happy")).toHaveLength(2); + expect(cheering()).toBe(false); + + rerender(); + expect(cheering()).toBe(false); + rerender(); + expect(cheering()).toBe(true); + act(() => vi.advanceTimersByTime(CHEER_MS)); + expect(cheering()).toBe(false); + + rerender(); + rerender(); + expect(cheering()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + test("the cheer ends at rest, so less motion shows none of it", () => { + const end = (name: string) => + cheerFrames + .match(new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`))?.[1] + ?.split(/100% \{/) + .at(-1); + expect(end("cheer-hop")).toContain("transform: translateY(0) scale(1, 1);"); + expect(end("cheer-open")).toMatch(/opacity: 1;\s*clip-path: inset\(0\);/); + expect(end("cheer-smile")).toContain("opacity: 0;"); + }); + test("framed sits on the icon square without the thin edge", () => { const { container } = render(); expect(container.querySelector("svg")?.getAttribute("class")).toContain("bg-[#0b0b0b]"); diff --git a/app/src/index.css b/app/src/index.css index f3e6150e..22570cea 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -7,6 +7,7 @@ @import "./motion.css"; @import "./mascot.css"; @import "./mascot-flame.css"; +@import "./mascot-cheer.css"; @import "./panels.css"; @import "./sidebar.css"; @import "./dialog.css"; diff --git a/app/src/mascot-cheer.css b/app/src/mascot-cheer.css new file mode 100644 index 00000000..bd646e6f --- /dev/null +++ b/app/src/mascot-cheer.css @@ -0,0 +1,98 @@ +/* The mascot cheers once when the bot finishes what it was doing + (BotAvatar.tsx sets data-cheer for CHEER_MS, spec 15.3): it crouches, + jumps up stretched, lands squashed and settles, while its eyes turn into + two arcs smiling up and come back open with a blink. Every frame ends at + rest, so less motion, which cuts the animation to its end, shows nothing. */ + +@layer components { + .avatar-happy { + opacity: 0; + transform-box: fill-box; + transform-origin: 50% 100%; + } + + [data-mood="idle"][data-cheer] .avatar-hop { + animation: cheer-hop 1.2s both; + } + + [data-mood="idle"][data-cheer] .avatar-eye { + animation: cheer-open 1.2s both; + } + + [data-mood="idle"][data-cheer] .avatar-happy { + animation: cheer-smile 1.2s both; + } + + [data-mood="idle"][data-cheer] .avatar-look { + animation: none; + } +} + +/* Up eases out, the fall eases in, and the landing gives a little. */ +@keyframes cheer-hop { + 0% { + transform: translateY(0) scale(1, 1); + animation-timing-function: ease-in-out; + } + 12% { + transform: translateY(0) scale(1.1, 0.9); + animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1); + } + 30% { + transform: translateY(-16%) scale(0.93, 1.08); + animation-timing-function: cubic-bezier(0.6, 0, 0.8, 0.4); + } + 46% { + transform: translateY(0) scale(1.1, 0.9); + animation-timing-function: ease-out; + } + 58% { + transform: translateY(-3%) scale(0.97, 1.03); + animation-timing-function: ease-in-out; + } + 70%, + 100% { + transform: translateY(0) scale(1, 1); + } +} + +/* The open eyes give way to the smile, then open again from the middle. */ +@keyframes cheer-open { + 0%, + 20% { + opacity: 1; + clip-path: inset(0); + } + 24%, + 84% { + opacity: 0; + clip-path: inset(50% 0); + } + 92%, + 100% { + opacity: 1; + clip-path: inset(0); + } +} + +@keyframes cheer-smile { + 0%, + 20% { + opacity: 0; + transform: scale(1, 0.4); + } + 24% { + opacity: 1; + transform: scale(1, 0.4); + } + 32%, + 82% { + opacity: 1; + transform: scale(1, 1); + } + 86%, + 100% { + opacity: 0; + transform: scale(1, 1); + } +} diff --git a/app/vite.config.ts b/app/vite.config.ts index 2c3441a1..043a2935 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -39,8 +39,8 @@ export default defineConfig(({ mode }) => ({ test: { environment: "jsdom", include: ["src/**/*.test.{ts,tsx}"], - // The mascot test reads the flame frames as text, and the messages test - // the color tokens; other CSS stays empty. - css: { include: [/mascot-flame\.css/, /src\/index\.css/] }, + // The mascot test reads the flame and cheer frames as text, and the + // messages test the color tokens; other CSS stays empty. + css: { include: [/mascot-(flame|cheer)\.css/, /src\/index\.css/] }, }, })); diff --git a/docs/spec.md b/docs/spec.md index aaba5928..083a95ec 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -871,7 +871,7 @@ Tamanho: a janela inteira é desenhada numa escala (zoom do webview, `setZoom`), Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animações curtas (150 a 400 ms, saída suave) em CSS, sem biblioteca (`motion.css`), e todas param quando o Windows desliga os efeitos de animação (`prefers-reduced-motion`) ou quando o dono liga "Menos animações" nas Configurações (`motion-less.css` repete as mesmas regras para `:root[data-motion="less"]`, e `reducedMotion()` olha os dois). -- **Mascote:** cada bot tem um humor que segue o estado, e a chama queima de verdade: o contorno muda de forma quadro a quadro e o sombreado acompanha (`mascot.css`, `mascot-flame.css`). Parado, a chama balança devagar e os olhos dão uma olhada para baixo e para o lado de vez em quando, como nas poses do desenho; trabalhando (`busy`, `launching`), queima forte e solta faíscas pelo alto; esperando o dono (`needs_approval`), inclina rápido e dá pulinhos; no limite de uso, queima baixo, de pálpebras pesadas que caem de vez em quando; pausado ou parado, vira uma brasa pequena e dorme, de olhos fechados em dois arcos. Os olhos piscam fechando de cima e de baixo, cada bot num ritmo, para uma equipe não piscar junta. Mexem-se os mascotes do cabeçalho e da resposta em andamento, e os da barra lateral e dos cartões da crew enquanto o bot está acordado, parado também: é pela chama devagar e pelas olhadas que o dono vê de relance que ele está livre. Nessas listas, só um bot dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro e um bot desligado não tem o que mostrar. O histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto, menos o de parado e livre (`idle`), que não aparece: a chama devagar já diz isso. +- **Mascote:** cada bot tem um humor que segue o estado, e a chama queima de verdade: o contorno muda de forma quadro a quadro e o sombreado acompanha (`mascot.css`, `mascot-flame.css`). Parado, a chama balança devagar e os olhos dão uma olhada para baixo e para o lado de vez em quando, como nas poses do desenho; trabalhando (`busy`, `launching`), queima forte e solta faíscas pelo alto; esperando o dono (`needs_approval`), inclina rápido e dá pulinhos; no limite de uso, queima baixo, de pálpebras pesadas que caem de vez em quando; pausado ou parado, vira uma brasa pequena e dorme, de olhos fechados em dois arcos. Quando termina o que fazia (de trabalhando para parado), comemora uma vez (`mascot-cheer.css`, 1,2 s): agacha, dá um pulo que estica a chama, pousa achatando, e os olhos viram dois arcos para cima, sorrindo, até voltarem abertos numa piscada; um mascote que já aparece parado não comemora. Os olhos piscam fechando de cima e de baixo, cada bot num ritmo, para uma equipe não piscar junta. Mexem-se os mascotes do cabeçalho e da resposta em andamento, e os da barra lateral e dos cartões da crew enquanto o bot está acordado, parado também: é pela chama devagar e pelas olhadas que o dono vê de relance que ele está livre. Nessas listas, só um bot dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro e um bot desligado não tem o que mostrar. O histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto, menos o de parado e livre (`idle`), que não aparece: a chama devagar já diz isso. - **Chegadas:** o que chega enquanto o dono olha entra subindo e aparecendo: mensagens, ferramentas, pedidos, avisos e bots novos na barra lateral. O que já estava lá quando ele abriu o chat (ou o app) aparece parado, e páginas antigas carregadas em cima não se mexem. A resposta já aparece enquanto é escrita, então não anima de novo ao terminar. - **Trabalhando:** os três pontos pulam em sequência; o texto sendo escrito tem um cursor piscando no fim; a mão de "Precisa de aprovação" acena; um pedido pendente pulsa uma vez ao aparecer. O estado de um bot que sai de parado aparece suave (o que já estava na tela ao abrir fica parado), e na linha de "Trabalhando" corre um trecho aceso (`StateIcons.tsx`); com menos movimento, a linha fica inteira acesa. - **Trocar de bot ou de crew:** o painel principal some rápido e o novo sobe (View Transitions do webview); a barra lateral fica onde está. From 6d33186a7ccf0466643e23a05f99a9827ac8ad15 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:49:08 -0300 Subject: [PATCH 2/3] fix(app): lean the shut and smiling eyes with the face The open eyes sit on a line that rises to the right (about 14 degrees), but the arcs of the shut and smiling eyes were drawn level, so they looked crooked next to it. Both arcs now turn by the angle of that line, in a group of their own so the smile's CSS transform keeps the turn. --- app/src/features/bots/BotAvatar.tsx | 48 ++++++++++++++++++--------- app/src/features/bots/avatar.test.tsx | 8 +++++ docs/spec.md | 2 +- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 267f1942..08037884 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -95,6 +95,18 @@ function glintsOn(eye: Ellipse): Ellipse[] { const rotate = ({ x, y, turn }: Ellipse) => `rotate(${turn} ${x} ${y})`; +/** + * How far the face leans, in degrees: the line through the two eyes, the + * right one sitting higher. Shut and smiling eyes lean with it. + */ +function tiltOf(eyes: readonly Ellipse[]): number { + const [left, right] = eyes; + if (!left || !right) return 0; + return (Math.atan2(right.y - left.y, right.x - left.x) * 180) / Math.PI; +} + +const TILT = tiltOf(EYES); + export function BotAvatar({ color, size = 28, @@ -224,22 +236,26 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string ))} - - + {/* The arcs lean in a group of their own: the smile's CSS transform + would replace a transform set on the path itself. */} + + + + ); } diff --git a/app/src/features/bots/avatar.test.tsx b/app/src/features/bots/avatar.test.tsx index cbd327cd..1466975c 100644 --- a/app/src/features/bots/avatar.test.tsx +++ b/app/src/features/bots/avatar.test.tsx @@ -122,6 +122,14 @@ describe("mascot", () => { } }); + test("shut and smiling eyes lean with the line through the open ones", () => { + const { container } = render(); + for (const arc of container.querySelectorAll(".avatar-closed, .avatar-happy")) { + const turn = arc.parentElement?.getAttribute("transform")?.match(/^rotate\((-?[\d.]+) /); + expect(Number(turn?.[1])).toBeCloseTo(-14.3, 0); + } + }); + test("the cheer ends at rest, so less motion shows none of it", () => { const end = (name: string) => cheerFrames diff --git a/docs/spec.md b/docs/spec.md index 083a95ec..f6b88dfa 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -871,7 +871,7 @@ Tamanho: a janela inteira é desenhada numa escala (zoom do webview, `setZoom`), Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animações curtas (150 a 400 ms, saída suave) em CSS, sem biblioteca (`motion.css`), e todas param quando o Windows desliga os efeitos de animação (`prefers-reduced-motion`) ou quando o dono liga "Menos animações" nas Configurações (`motion-less.css` repete as mesmas regras para `:root[data-motion="less"]`, e `reducedMotion()` olha os dois). -- **Mascote:** cada bot tem um humor que segue o estado, e a chama queima de verdade: o contorno muda de forma quadro a quadro e o sombreado acompanha (`mascot.css`, `mascot-flame.css`). Parado, a chama balança devagar e os olhos dão uma olhada para baixo e para o lado de vez em quando, como nas poses do desenho; trabalhando (`busy`, `launching`), queima forte e solta faíscas pelo alto; esperando o dono (`needs_approval`), inclina rápido e dá pulinhos; no limite de uso, queima baixo, de pálpebras pesadas que caem de vez em quando; pausado ou parado, vira uma brasa pequena e dorme, de olhos fechados em dois arcos. Quando termina o que fazia (de trabalhando para parado), comemora uma vez (`mascot-cheer.css`, 1,2 s): agacha, dá um pulo que estica a chama, pousa achatando, e os olhos viram dois arcos para cima, sorrindo, até voltarem abertos numa piscada; um mascote que já aparece parado não comemora. Os olhos piscam fechando de cima e de baixo, cada bot num ritmo, para uma equipe não piscar junta. Mexem-se os mascotes do cabeçalho e da resposta em andamento, e os da barra lateral e dos cartões da crew enquanto o bot está acordado, parado também: é pela chama devagar e pelas olhadas que o dono vê de relance que ele está livre. Nessas listas, só um bot dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro e um bot desligado não tem o que mostrar. O histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto, menos o de parado e livre (`idle`), que não aparece: a chama devagar já diz isso. +- **Mascote:** cada bot tem um humor que segue o estado, e a chama queima de verdade: o contorno muda de forma quadro a quadro e o sombreado acompanha (`mascot.css`, `mascot-flame.css`). Parado, a chama balança devagar e os olhos dão uma olhada para baixo e para o lado de vez em quando, como nas poses do desenho; trabalhando (`busy`, `launching`), queima forte e solta faíscas pelo alto; esperando o dono (`needs_approval`), inclina rápido e dá pulinhos; no limite de uso, queima baixo, de pálpebras pesadas que caem de vez em quando; pausado ou parado, vira uma brasa pequena e dorme, de olhos fechados em dois arcos, inclinados como os olhos abertos. Quando termina o que fazia (de trabalhando para parado), comemora uma vez (`mascot-cheer.css`, 1,2 s): agacha, dá um pulo que estica a chama, pousa achatando, e os olhos viram dois arcos para cima, sorrindo, até voltarem abertos numa piscada; um mascote que já aparece parado não comemora. Os olhos piscam fechando de cima e de baixo, cada bot num ritmo, para uma equipe não piscar junta. Mexem-se os mascotes do cabeçalho e da resposta em andamento, e os da barra lateral e dos cartões da crew enquanto o bot está acordado, parado também: é pela chama devagar e pelas olhadas que o dono vê de relance que ele está livre. Nessas listas, só um bot dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro e um bot desligado não tem o que mostrar. O histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto, menos o de parado e livre (`idle`), que não aparece: a chama devagar já diz isso. - **Chegadas:** o que chega enquanto o dono olha entra subindo e aparecendo: mensagens, ferramentas, pedidos, avisos e bots novos na barra lateral. O que já estava lá quando ele abriu o chat (ou o app) aparece parado, e páginas antigas carregadas em cima não se mexem. A resposta já aparece enquanto é escrita, então não anima de novo ao terminar. - **Trabalhando:** os três pontos pulam em sequência; o texto sendo escrito tem um cursor piscando no fim; a mão de "Precisa de aprovação" acena; um pedido pendente pulsa uma vez ao aparecer. O estado de um bot que sai de parado aparece suave (o que já estava na tela ao abrir fica parado), e na linha de "Trabalhando" corre um trecho aceso (`StateIcons.tsx`); com menos movimento, a linha fica inteira acesa. - **Trocar de bot ou de crew:** o painel principal some rápido e o novo sobe (View Transitions do webview); a barra lateral fica onde está. From 83d761a20d5c6b5d7c3450cad1c1eea7b8284c6f Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:02:12 -0300 Subject: [PATCH 3/3] feat(app): mascot reel page and a recorder for videos A dev-only page (?reel) shows a row of bots in many colors passing by: every beat the row slides one place, and 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. `pnpm reel` records it to a 60 fps video. It stops the page's clock (timers, animation frames, Date and performance.now, CSS animations), moves it a sixtieth of a second at a time and takes a picture of each frame, so the video comes out the same on any computer. --blur blends each frame with its neighbors. The browser's own frame stream was tried first and dropped: its time stamps run out of order and it stopped sending frames after a few seconds. --- CLAUDE.md | 1 + app/package.json | 1 + app/scripts/reel.mjs | 235 ++++++++++++++++++++++++++++++++++++++ app/scripts/reelClock.js | 90 +++++++++++++++ app/src/dev/Reel.tsx | 89 +++++++++++++++ app/src/dev/reel.css | 55 +++++++++ app/src/dev/reel.test.tsx | 29 +++++ app/src/main.tsx | 43 ++++--- 8 files changed, 524 insertions(+), 19 deletions(-) create mode 100644 app/scripts/reel.mjs create mode 100644 app/scripts/reelClock.js create mode 100644 app/src/dev/Reel.tsx create mode 100644 app/src/dev/reel.css create mode 100644 app/src/dev/reel.test.tsx 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/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( + + + , + ); + }); +}