Skip to content

Commit 6ead13f

Browse files
committed
perf(app): the mascots' eyes move in moments, not forever
Blinking and glancing ran as infinite animations, which keep the WebView drawing every frame. A scheduler now picks, every two seconds, which mascots on screen blink (each about every 7 s, a little apart) and now and then lets one idle mascot look around; in between the eyes stand still. Measured in the dev app with the flame running: 1.0-1.2 cores instead of 1.35-1.5.
1 parent 3e15106 commit 6ead13f

5 files changed

Lines changed: 175 additions & 30 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2+
import { startEyeMoments } from "./eyeMoments";
3+
4+
let stop: () => void = () => {};
5+
6+
function mascot(mood: string, still = false): SVGElement {
7+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
8+
svg.setAttribute("data-mood", mood);
9+
if (still) svg.setAttribute("data-still", "");
10+
svg.getBoundingClientRect = () =>
11+
({ width: 28, height: 28, top: 10, bottom: 38, left: 0, right: 28 }) as DOMRect;
12+
document.body.append(svg);
13+
return svg;
14+
}
15+
16+
beforeEach(() => {
17+
vi.useFakeTimers();
18+
});
19+
20+
afterEach(() => {
21+
stop();
22+
document.body.replaceChildren();
23+
document.documentElement.removeAttribute("data-blurred");
24+
vi.useRealTimers();
25+
});
26+
27+
describe("the eyes' moments", () => {
28+
test("awake mascots blink for a moment, then stand still", () => {
29+
const idle = mascot("idle");
30+
const sleeping = mascot("sleeping");
31+
const still = mascot("idle", true);
32+
// Every draw says yes, with no spread.
33+
stop = startEyeMoments(() => 0);
34+
// A moment starts on its own timer, right after the tick.
35+
vi.advanceTimersByTime(2001);
36+
expect(idle.hasAttribute("data-blink")).toBe(true);
37+
expect(sleeping.hasAttribute("data-blink")).toBe(false);
38+
expect(still.hasAttribute("data-blink")).toBe(false);
39+
vi.advanceTimersByTime(300);
40+
expect(idle.hasAttribute("data-blink")).toBe(false);
41+
});
42+
43+
test("one idle mascot looks around now and then, not on every tick", () => {
44+
const idle = mascot("idle");
45+
const working = mascot("working");
46+
stop = startEyeMoments(() => 0.99);
47+
vi.advanceTimersByTime(8000);
48+
expect(idle.hasAttribute("data-look")).toBe(false);
49+
vi.advanceTimersByTime(2001);
50+
expect(idle.hasAttribute("data-look")).toBe(true);
51+
expect(working.hasAttribute("data-look")).toBe(false);
52+
vi.advanceTimersByTime(4500);
53+
expect(idle.hasAttribute("data-look")).toBe(false);
54+
});
55+
56+
test("nothing moves while the window has no focus", () => {
57+
const idle = mascot("idle");
58+
document.documentElement.setAttribute("data-blurred", "");
59+
stop = startEyeMoments(() => 0);
60+
vi.advanceTimersByTime(20_000);
61+
expect(idle.hasAttribute("data-blink")).toBe(false);
62+
expect(idle.hasAttribute("data-look")).toBe(false);
63+
});
64+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// When the mascots blink and glance around (spec 15.3). An animation that
2+
// runs forever makes the WebView draw every frame, so the eyes do not run
3+
// one: every couple of seconds this picks which mascots on screen blink,
4+
// each a little apart, and now and then one idle mascot looks around.
5+
// Between those moments the eyes stand still and cost nothing. Paused while
6+
// the window is out of sight or has no focus, and with less motion.
7+
8+
/** How often, on average, each mascot blinks. */
9+
const BLINK_EVERY_MS = 7000;
10+
/** How often the scheduler looks; blinks of one look start together. */
11+
const TICK_MS = 2000;
12+
/** How far apart blinks of the same look may start. */
13+
const SPREAD_MS = 600;
14+
/** A blink, as `eye-blink` in mascot.css; a tired droop takes longer. */
15+
const BLINK_MS = 260;
16+
const DROOP_MS = 800;
17+
/** One idle mascot looks around, at most this often, for this long. */
18+
const LOOK_EVERY_MS = [9000, 16000] as const;
19+
const LOOK_MS = 4400;
20+
21+
function resting(): boolean {
22+
const root = document.documentElement;
23+
return (
24+
document.visibilityState === "hidden" ||
25+
root.hasAttribute("data-hidden") ||
26+
root.hasAttribute("data-blurred") ||
27+
root.getAttribute("data-motion") === "less" ||
28+
(typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches)
29+
);
30+
}
31+
32+
/** The mascots that may move their eyes: on screen, awake, not held still. */
33+
function awake(): SVGElement[] {
34+
const all = document.querySelectorAll<SVGElement>(
35+
'svg[data-mood]:not([data-mood="sleeping"]):not([data-still])',
36+
);
37+
return [...all].filter((svg) => {
38+
const box = svg.getBoundingClientRect();
39+
return box.width > 0 && box.bottom > 0 && box.top < innerHeight;
40+
});
41+
}
42+
43+
/** Sets `attribute` on `svg` for `ms`, after `delay`. */
44+
function moment(svg: SVGElement, attribute: string, delay: number, ms: number): void {
45+
setTimeout(() => {
46+
svg.setAttribute(attribute, "");
47+
setTimeout(() => svg.removeAttribute(attribute), ms);
48+
}, delay);
49+
}
50+
51+
/** Starts the eyes' moments; returns what stops them. */
52+
export function startEyeMoments(random: () => number = Math.random): () => void {
53+
let nextLook = performance.now() + LOOK_EVERY_MS[0];
54+
const tick = () => {
55+
if (resting()) {
56+
return;
57+
}
58+
const mascots = awake();
59+
for (const svg of mascots) {
60+
if (random() < TICK_MS / BLINK_EVERY_MS) {
61+
const tired = svg.getAttribute("data-mood") === "tired";
62+
moment(svg, "data-blink", random() * SPREAD_MS, tired ? DROOP_MS : BLINK_MS);
63+
}
64+
}
65+
const now = performance.now();
66+
const idle = mascots.filter((svg) => svg.getAttribute("data-mood") === "idle");
67+
if (now >= nextLook && idle.length > 0) {
68+
const svg = idle[Math.floor(random() * idle.length)] as SVGElement;
69+
moment(svg, "data-look", 0, LOOK_MS);
70+
const [least, most] = LOOK_EVERY_MS;
71+
nextLook = now + least + random() * (most - least);
72+
}
73+
};
74+
const timer = setInterval(tick, TICK_MS);
75+
return () => clearInterval(timer);
76+
}

‎app/src/main.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
22
import { createRoot } from "react-dom/client";
33
import packageJson from "../package.json";
44
import { App } from "./App";
5+
import { startEyeMoments } from "./features/bots/eyeMoments";
56
import type { Connect } from "./features/onboarding/link";
67
import { connect, rpcUrl } from "./lib/client";
78
import type { Host } from "./lib/host";
@@ -43,6 +44,8 @@ if (import.meta.env.DEV && new URLSearchParams(window.location.search).has("reel
4344
document.documentElement.dataset.motion = "less";
4445
}
4546
watchVisibility();
47+
// The mascots blink and look around in moments, not forever.
48+
startEyeMoments();
4649
// Opened at sign-in, the window stays hidden until the owner opens it.
4750
host.launchedAtSignIn().then(
4851
(hidden) => hidden && !document.hasFocus() && markHidden(true),

‎app/src/mascot.css‎

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,24 @@
3333
transition: opacity 320ms var(--ease-out);
3434
}
3535

36-
[data-mood] .avatar-eye {
37-
animation: eye-blink 7s infinite;
38-
animation-delay: var(--blink-delay, 0s);
36+
[data-mood="sleeping"] .avatar-eye {
37+
opacity: 0;
38+
animation: none;
39+
}
40+
41+
/* The eyes move only in moments eyeMoments.ts picks: an animation that
42+
runs forever makes the WebView draw every frame. */
43+
[data-mood][data-blink] .avatar-eye {
44+
animation: eye-blink 260ms;
3945
}
4046

4147
[data-mood="idle"] .avatar-fire {
4248
animation: fire-idle 3.6s linear infinite;
4349
animation-delay: var(--blink-delay, 0s);
4450
}
4551

46-
[data-mood="idle"] .avatar-look {
47-
animation: eye-look 11s ease-in-out infinite;
48-
animation-delay: var(--blink-delay, 0s);
52+
[data-mood="idle"][data-look] .avatar-look {
53+
animation: eye-look 4.4s ease-in-out;
4954
}
5055

5156
[data-mood="working"] .avatar-fire {
@@ -69,24 +74,26 @@
6974
animation: hop 2.6s var(--ease-out) infinite;
7075
}
7176

72-
/* Heavy lids, slanting down to the outside, that droop now and then. */
77+
/* Heavy lids, slanting down to the outside, that droop in the moments
78+
a blink would come. */
7379
[data-mood="tired"] .avatar-eye-l {
7480
clip-path: polygon(0 34%, 100% 22%, 100% 100%, 0 100%);
75-
animation: eye-tired-l 6s infinite;
7681
}
7782

7883
[data-mood="tired"] .avatar-eye-r {
7984
clip-path: polygon(0 22%, 100% 34%, 100% 100%, 0 100%);
80-
animation: eye-tired-r 6s infinite;
8185
}
8286

83-
[data-mood="tired"] .avatar-hl {
84-
transform: translateY(12px);
87+
[data-mood="tired"][data-blink] .avatar-eye-l {
88+
animation: eye-tired-l 800ms;
8589
}
8690

87-
[data-mood="sleeping"] .avatar-eye {
88-
opacity: 0;
89-
animation: none;
91+
[data-mood="tired"][data-blink] .avatar-eye-r {
92+
animation: eye-tired-r 800ms;
93+
}
94+
95+
[data-mood="tired"] .avatar-hl {
96+
transform: translateY(12px);
9097
}
9198

9299
[data-mood="sleeping"] .avatar-closed {
@@ -108,59 +115,54 @@
108115

109116
@keyframes eye-blink {
110117
0%,
111-
91%,
112118
100% {
113119
clip-path: inset(0);
114120
}
115-
94% {
121+
45% {
116122
clip-path: inset(50% 0);
117123
}
118124
}
119125

120126
/* A glance down to the left, back, then up to the right, resting between. */
121127
@keyframes eye-look {
122128
0%,
123-
30%,
129+
44%,
124130
56%,
125-
64%,
126-
92%,
127131
100% {
128132
transform: translate(0, 0);
129133
}
130-
36%,
131-
50% {
134+
10%,
135+
34% {
132136
transform: translate(-3px, 10px);
133137
}
134-
70%,
135-
86% {
138+
66%,
139+
90% {
136140
transform: translate(7px, -6px);
137141
}
138142
}
139143

140144
@keyframes eye-tired-l {
141145
0%,
142-
78%,
143146
100% {
144147
clip-path: polygon(0 34%, 100% 22%, 100% 100%, 0 100%);
145148
}
146-
84% {
149+
30% {
147150
clip-path: polygon(0 58%, 100% 46%, 100% 100%, 0 100%);
148151
}
149-
90% {
152+
60% {
150153
clip-path: polygon(0 30%, 100% 18%, 100% 100%, 0 100%);
151154
}
152155
}
153156

154157
@keyframes eye-tired-r {
155158
0%,
156-
78%,
157159
100% {
158160
clip-path: polygon(0 22%, 100% 34%, 100% 100%, 0 100%);
159161
}
160-
84% {
162+
30% {
161163
clip-path: polygon(0 46%, 100% 58%, 100% 100%, 0 100%);
162164
}
163-
90% {
165+
60% {
164166
clip-path: polygon(0 18%, 100% 30%, 100% 100%, 0 100%);
165167
}
166168
}

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -895,7 +895,7 @@ Tamanho: a janela inteira é desenhada numa escala (zoom do webview, `setZoom`),
895895

896896
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).
897897

898-
- **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.
898+
- **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.
899899
- **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.
900900
- **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.
901901
- **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).

0 commit comments

Comments
 (0)