Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions app/src/features/bots/eyeMoments.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
76 changes: 76 additions & 0 deletions app/src/features/bots/eyeMoments.ts
Original file line number Diff line number Diff line change
@@ -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<SVGElement>(
'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);
}
3 changes: 3 additions & 0 deletions app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
60 changes: 31 additions & 29 deletions app/src/mascot.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,24 @@
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 {
animation: fire-idle 3.6s linear infinite;
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 {
Expand All @@ -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 {
Expand All @@ -108,59 +115,54 @@

@keyframes eye-blink {
0%,
91%,
100% {
clip-path: inset(0);
}
94% {
45% {
clip-path: inset(50% 0);
}
}

/* 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%);
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading