diff --git a/app/src/features/bots/eyeMoments.test.ts b/app/src/features/bots/eyeMoments.test.ts new file mode 100644 index 00000000..553d5b57 --- /dev/null +++ b/app/src/features/bots/eyeMoments.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { startEyeMoments } from "./eyeMoments"; + +let stop: () => void = () => {}; + +function mascot(mood: string, still = false): SVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("data-mood", mood); + if (still) svg.setAttribute("data-still", ""); + svg.getBoundingClientRect = () => + ({ width: 28, height: 28, top: 10, bottom: 38, left: 0, right: 28 }) as DOMRect; + document.body.append(svg); + return svg; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + stop(); + document.body.replaceChildren(); + document.documentElement.removeAttribute("data-blurred"); + vi.useRealTimers(); +}); + +describe("the eyes' moments", () => { + test("awake mascots blink for a moment, then stand still", () => { + const idle = mascot("idle"); + const sleeping = mascot("sleeping"); + const still = mascot("idle", true); + // Every draw says yes, with no spread. + stop = startEyeMoments(() => 0); + // A moment starts on its own timer, right after the tick. + vi.advanceTimersByTime(2001); + expect(idle.hasAttribute("data-blink")).toBe(true); + expect(sleeping.hasAttribute("data-blink")).toBe(false); + expect(still.hasAttribute("data-blink")).toBe(false); + vi.advanceTimersByTime(300); + expect(idle.hasAttribute("data-blink")).toBe(false); + }); + + test("one idle mascot looks around now and then, not on every tick", () => { + const idle = mascot("idle"); + const working = mascot("working"); + stop = startEyeMoments(() => 0.99); + vi.advanceTimersByTime(8000); + expect(idle.hasAttribute("data-look")).toBe(false); + vi.advanceTimersByTime(2001); + expect(idle.hasAttribute("data-look")).toBe(true); + expect(working.hasAttribute("data-look")).toBe(false); + vi.advanceTimersByTime(4500); + expect(idle.hasAttribute("data-look")).toBe(false); + }); + + test("nothing moves while the window has no focus", () => { + const idle = mascot("idle"); + document.documentElement.setAttribute("data-blurred", ""); + stop = startEyeMoments(() => 0); + vi.advanceTimersByTime(20_000); + expect(idle.hasAttribute("data-blink")).toBe(false); + expect(idle.hasAttribute("data-look")).toBe(false); + }); +}); diff --git a/app/src/features/bots/eyeMoments.ts b/app/src/features/bots/eyeMoments.ts new file mode 100644 index 00000000..3fd293fb --- /dev/null +++ b/app/src/features/bots/eyeMoments.ts @@ -0,0 +1,76 @@ +// When the mascots blink and glance around (spec 15.3). An animation that +// runs forever makes the WebView draw every frame, so the eyes do not run +// one: every couple of seconds this picks which mascots on screen blink, +// each a little apart, and now and then one idle mascot looks around. +// Between those moments the eyes stand still and cost nothing. Paused while +// the window is out of sight or has no focus, and with less motion. + +/** How often, on average, each mascot blinks. */ +const BLINK_EVERY_MS = 7000; +/** How often the scheduler looks; blinks of one look start together. */ +const TICK_MS = 2000; +/** How far apart blinks of the same look may start. */ +const SPREAD_MS = 600; +/** A blink, as `eye-blink` in mascot.css; a tired droop takes longer. */ +const BLINK_MS = 260; +const DROOP_MS = 800; +/** One idle mascot looks around, at most this often, for this long. */ +const LOOK_EVERY_MS = [9000, 16000] as const; +const LOOK_MS = 4400; + +function resting(): boolean { + const root = document.documentElement; + return ( + document.visibilityState === "hidden" || + root.hasAttribute("data-hidden") || + root.hasAttribute("data-blurred") || + root.getAttribute("data-motion") === "less" || + (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) + ); +} + +/** The mascots that may move their eyes: on screen, awake, not held still. */ +function awake(): SVGElement[] { + const all = document.querySelectorAll( + 'svg[data-mood]:not([data-mood="sleeping"]):not([data-still])', + ); + return [...all].filter((svg) => { + const box = svg.getBoundingClientRect(); + return box.width > 0 && box.bottom > 0 && box.top < innerHeight; + }); +} + +/** Sets `attribute` on `svg` for `ms`, after `delay`. */ +function moment(svg: SVGElement, attribute: string, delay: number, ms: number): void { + setTimeout(() => { + svg.setAttribute(attribute, ""); + setTimeout(() => svg.removeAttribute(attribute), ms); + }, delay); +} + +/** Starts the eyes' moments; returns what stops them. */ +export function startEyeMoments(random: () => number = Math.random): () => void { + let nextLook = performance.now() + LOOK_EVERY_MS[0]; + const tick = () => { + if (resting()) { + return; + } + const mascots = awake(); + for (const svg of mascots) { + if (random() < TICK_MS / BLINK_EVERY_MS) { + const tired = svg.getAttribute("data-mood") === "tired"; + moment(svg, "data-blink", random() * SPREAD_MS, tired ? DROOP_MS : BLINK_MS); + } + } + const now = performance.now(); + const idle = mascots.filter((svg) => svg.getAttribute("data-mood") === "idle"); + if (now >= nextLook && idle.length > 0) { + const svg = idle[Math.floor(random() * idle.length)] as SVGElement; + moment(svg, "data-look", 0, LOOK_MS); + const [least, most] = LOOK_EVERY_MS; + nextLook = now + least + random() * (most - least); + } + }; + const timer = setInterval(tick, TICK_MS); + return () => clearInterval(timer); +} diff --git a/app/src/main.tsx b/app/src/main.tsx index 8691bf92..39e25a73 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import packageJson from "../package.json"; import { App } from "./App"; +import { startEyeMoments } from "./features/bots/eyeMoments"; import type { Connect } from "./features/onboarding/link"; import { connect, rpcUrl } from "./lib/client"; import type { Host } from "./lib/host"; @@ -43,6 +44,8 @@ if (import.meta.env.DEV && new URLSearchParams(window.location.search).has("reel document.documentElement.dataset.motion = "less"; } watchVisibility(); + // The mascots blink and look around in moments, not forever. + startEyeMoments(); // Opened at sign-in, the window stays hidden until the owner opens it. host.launchedAtSignIn().then( (hidden) => hidden && !document.hasFocus() && markHidden(true), diff --git a/app/src/mascot.css b/app/src/mascot.css index 0bfc6a46..697d3321 100644 --- a/app/src/mascot.css +++ b/app/src/mascot.css @@ -33,9 +33,15 @@ transition: opacity 320ms var(--ease-out); } - [data-mood] .avatar-eye { - animation: eye-blink 7s infinite; - animation-delay: var(--blink-delay, 0s); + [data-mood="sleeping"] .avatar-eye { + opacity: 0; + animation: none; + } + + /* The eyes move only in moments eyeMoments.ts picks: an animation that + runs forever makes the WebView draw every frame. */ + [data-mood][data-blink] .avatar-eye { + animation: eye-blink 260ms; } [data-mood="idle"] .avatar-fire { @@ -43,9 +49,8 @@ animation-delay: var(--blink-delay, 0s); } - [data-mood="idle"] .avatar-look { - animation: eye-look 11s ease-in-out infinite; - animation-delay: var(--blink-delay, 0s); + [data-mood="idle"][data-look] .avatar-look { + animation: eye-look 4.4s ease-in-out; } [data-mood="working"] .avatar-fire { @@ -69,24 +74,26 @@ animation: hop 2.6s var(--ease-out) infinite; } - /* Heavy lids, slanting down to the outside, that droop now and then. */ + /* Heavy lids, slanting down to the outside, that droop in the moments + a blink would come. */ [data-mood="tired"] .avatar-eye-l { clip-path: polygon(0 34%, 100% 22%, 100% 100%, 0 100%); - animation: eye-tired-l 6s infinite; } [data-mood="tired"] .avatar-eye-r { clip-path: polygon(0 22%, 100% 34%, 100% 100%, 0 100%); - animation: eye-tired-r 6s infinite; } - [data-mood="tired"] .avatar-hl { - transform: translateY(12px); + [data-mood="tired"][data-blink] .avatar-eye-l { + animation: eye-tired-l 800ms; } - [data-mood="sleeping"] .avatar-eye { - opacity: 0; - animation: none; + [data-mood="tired"][data-blink] .avatar-eye-r { + animation: eye-tired-r 800ms; + } + + [data-mood="tired"] .avatar-hl { + transform: translateY(12px); } [data-mood="sleeping"] .avatar-closed { @@ -108,11 +115,10 @@ @keyframes eye-blink { 0%, - 91%, 100% { clip-path: inset(0); } - 94% { + 45% { clip-path: inset(50% 0); } } @@ -120,47 +126,43 @@ /* A glance down to the left, back, then up to the right, resting between. */ @keyframes eye-look { 0%, - 30%, + 44%, 56%, - 64%, - 92%, 100% { transform: translate(0, 0); } - 36%, - 50% { + 10%, + 34% { transform: translate(-3px, 10px); } - 70%, - 86% { + 66%, + 90% { transform: translate(7px, -6px); } } @keyframes eye-tired-l { 0%, - 78%, 100% { clip-path: polygon(0 34%, 100% 22%, 100% 100%, 0 100%); } - 84% { + 30% { clip-path: polygon(0 58%, 100% 46%, 100% 100%, 0 100%); } - 90% { + 60% { clip-path: polygon(0 30%, 100% 18%, 100% 100%, 0 100%); } } @keyframes eye-tired-r { 0%, - 78%, 100% { clip-path: polygon(0 22%, 100% 34%, 100% 100%, 0 100%); } - 84% { + 30% { clip-path: polygon(0 46%, 100% 58%, 100% 100%, 0 100%); } - 90% { + 60% { clip-path: polygon(0 18%, 100% 30%, 100% 100%, 0 100%); } } diff --git a/docs/spec.md b/docs/spec.md index 4b49f001..2096986d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -895,7 +895,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, 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. +- **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. Os olhos não têm animação contínua: um agendador (`eyeMoments.ts`) passa a cada 2 s e sorteia quais mascotes à vista piscam (cada um em média a cada 7 s, com até 0,6 s de diferença entre eles; o piscar dura 260 ms, a queda das pálpebras pesadas 0,8 s), e de 9 a 16 s um mascote parado dá a olhada (4,4 s). Entre esses momentos os olhos ficam parados, porque uma animação sem fim faz o WebView desenhar todo quadro: medido, os olhos assim gastam 0,2 a 0,5 núcleo a menos. Com a janela fora de vista, sem foco ou com menos movimento, não há momentos. 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).