diff --git a/app/src/paused.css b/app/src/paused.css index 3bec8271..e98aa19d 100644 --- a/app/src/paused.css +++ b/app/src/paused.css @@ -8,3 +8,12 @@ animation-play-state: paused !important; } /* biome-ignore-end lint/complexity/noImportantStyles: a hidden window must pause every animation, inline ones included */ + +/* The owner works in another app (shell/visibility.ts): the mascots and the + working trace, drawn by the CPU on every frame, stand still. Spinners, + live dots and what arrives keep moving: they are cheap and say something + happens. */ +:root[data-blurred] [data-mood] *, +:root[data-blurred] .state-trace { + animation-play-state: paused; +} diff --git a/app/src/shell/visibility.test.tsx b/app/src/shell/visibility.test.tsx index e624674a..6375212b 100644 --- a/app/src/shell/visibility.test.tsx +++ b/app/src/shell/visibility.test.tsx @@ -29,6 +29,16 @@ describe("window visibility", () => { expect(document.documentElement.hasAttribute("data-hidden")).toBe(false); }); + test("the mascots stand still while another app has the focus", () => { + const focus = vi.spyOn(document, "hasFocus").mockReturnValue(false); + window.dispatchEvent(new Event("blur")); + expect(document.documentElement.hasAttribute("data-blurred")).toBe(true); + focus.mockReturnValue(true); + window.dispatchEvent(new Event("focus")); + expect(document.documentElement.hasAttribute("data-blurred")).toBe(false); + focus.mockRestore(); + }); + test("the status is not polled while the window is hidden, and is read when it comes back", async () => { vi.useFakeTimers(); const fake = new FakeBotloft(); diff --git a/app/src/shell/visibility.ts b/app/src/shell/visibility.ts index 26d8bb5a..9f02c9de 100644 --- a/app/src/shell/visibility.ts +++ b/app/src/shell/visibility.ts @@ -36,11 +36,25 @@ export function onVisibility(listener: () => void): () => void { return () => listeners.delete(listener); } +/** + * Whether the window has the focus: while the owner works in another app, + * the mascots stand still (paused.css), so a Botloft left open in the + * background costs next to nothing. + */ +function focusChanged(): void { + document.documentElement.toggleAttribute("data-blurred", !document.hasFocus()); +} + /** Starts following the window; once, before the app renders. */ export function watchVisibility(): void { document.addEventListener("visibilitychange", changed); - window.addEventListener("focus", () => markHidden(false)); + window.addEventListener("focus", () => { + markHidden(false); + focusChanged(); + }); + window.addEventListener("blur", focusChanged); changed(); + focusChanged(); } export function useWindowVisible(): boolean { diff --git a/docs/spec.md b/docs/spec.md index 4b49f001..fe4bbbe7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -808,7 +808,7 @@ app/src/ Componentes dependem só de `BotloftApi` e `Host`, nunca do cliente concreto nem do Tauri. -O store recarrega crews, bots e `system.status` a cada (re)conexão e depois segue as notificações. `system.status` não tem notificação e é relido a cada 15 s, menos com a janela fora de vista (minimizada, escondida perto do relógio ou aberta escondida no login do Windows): aí para, e é relido quando ela volta. Fora de vista, as animações também param onde estão (`paused.css`) e continuam quando a janela volta. O Windows pode não avisar a página quando o próprio app esconde a janela, então o app marca isso ele mesmo (`shell/visibility.ts`), e a janela voltar à frente desmarca. +O store recarrega crews, bots e `system.status` a cada (re)conexão e depois segue as notificações. `system.status` não tem notificação e é relido a cada 15 s, menos com a janela fora de vista (minimizada, escondida perto do relógio ou aberta escondida no login do Windows): aí para, e é relido quando ela volta. Fora de vista, as animações também param onde estão (`paused.css`) e continuam quando a janela volta. O Windows pode não avisar a página quando o próprio app esconde a janela, então o app marca isso ele mesmo (`shell/visibility.ts`), e a janela voltar à frente desmarca. Com a janela à vista mas sem o foco (o dono trabalha em outro app), os mascotes e o traço de "trabalhando" param onde estão, porque o WebView os redesenha na CPU a cada quadro: um Botloft aberto atrás de outras janelas custa quase nada. Spinners, pontos ao vivo e o que chega continuam. Medido no WebView2 com bots parados: cerca de 1 núcleo com os mascotes animando, 0,01 com eles parados. Enquanto alguma animação roda, o WebView produz um quadro por atualização da tela (180 Hz no monitor do dono), então limitar os quadros de cada animação (`steps`) não muda o custo; parar é o que muda. O chat e as mensagens de uma equipe guardam todas as páginas já carregadas. As linhas fora de vista não são diagramadas nem pintadas até chegarem perto da tela (`content-visibility: auto`, em `offscreen.css`), e guardam o tamanho que tiveram para a barra de rolagem não pular. As 8 mais novas ficam de fora: é onde os cartões chegam com o brilho de atenção, que a contenção cortaria.