From 867e4a586625df484a3edb5851a56a86118f5613 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:25:42 -0300 Subject: [PATCH] fix(app): rise a new pane in without a view transition As a view transition ended, WebView2 sometimes painted the header's mascot without its clip for about 70 ms: an orange square. Recorded frame by frame in the installed app, it showed up within seconds of switching chats with the transition and never without it. The new bot's or crew's pane now rises in with a plain opacity and transform animation; the old pane no longer fades out. --- app/src/features/account/prefs.test.tsx | 4 +- app/src/motion-less.css | 6 --- app/src/motion.css | 31 +++------------ app/src/shell/Workspace.tsx | 32 ++++++++++------ app/src/shell/motionRoot.ts | 2 +- app/src/store/app.ts | 9 ++--- app/src/ui/motion.test.tsx | 51 ++++++++++--------------- app/src/ui/motion.ts | 33 +--------------- docs/spec.md | 2 +- 9 files changed, 54 insertions(+), 116 deletions(-) diff --git a/app/src/features/account/prefs.test.tsx b/app/src/features/account/prefs.test.tsx index cf239fb3..39e8d5ce 100644 --- a/app/src/features/account/prefs.test.tsx +++ b/app/src/features/account/prefs.test.tsx @@ -3,7 +3,6 @@ import { afterEach, describe, expect, test } from "vitest"; import { FakeBotloft } from "../../lib/fake"; import { prefs, resetPrefs } from "../../shell/prefs"; import { crewOpened, openBot, renderApp } from "../../test/app"; -import { reducedMotion } from "../../ui/motion"; afterEach(() => { cleanup(); @@ -68,10 +67,9 @@ describe("the app's own choices", () => { test("less motion stills the window", async () => { renderApp(new FakeBotloft()); const dialog = await openSettings("Appearance"); - expect(reducedMotion()).toBe(false); + expect(document.documentElement.dataset.motion).toBeUndefined(); fireEvent.click(within(dialog).getByRole("switch", { name: "Less motion" })); expect(document.documentElement.dataset.motion).toBe("less"); - expect(reducedMotion()).toBe(true); fireEvent.click(within(dialog).getByRole("switch", { name: "Less motion" })); expect(document.documentElement.dataset.motion).toBeUndefined(); }); diff --git a/app/src/motion-less.css b/app/src/motion-less.css index 1bb3170e..5e351627 100644 --- a/app/src/motion-less.css +++ b/app/src/motion-less.css @@ -12,12 +12,6 @@ transition-duration: 0.01ms !important; } -:root[data-motion="less"]::view-transition-group(*), -:root[data-motion="less"]::view-transition-old(*), -:root[data-motion="less"]::view-transition-new(*) { - animation: none !important; -} - /* A still stretch would read as a broken line: light up all of it. */ :root[data-motion="less"] .state-trace { stroke-dasharray: none; diff --git a/app/src/motion.css b/app/src/motion.css index 9aceac3d..dfa5a54d 100644 --- a/app/src/motion.css +++ b/app/src/motion.css @@ -241,25 +241,12 @@ } } -/* Switching bots or crews: the main pane fades out and the new one rises - in; the rest of the window stays put (ui/motion.ts). */ -::view-transition-old(root), -::view-transition-new(root) { - animation: none; -} - -::view-transition-old(main-pane) { - animation: 110ms ease-out both view-out; -} - -::view-transition-new(main-pane) { - animation: 240ms var(--ease-out) both view-in; -} - -@keyframes view-out { - to { - opacity: 0; - } +/* Switching bots or crews: the new main pane rises in; the rest of the + window stays put (shell/Workspace.tsx). A plain animation, not a view + transition: as one ended, WebView2 painted a mascot without its clip + for a few frames, an orange square. */ +.pane-rise { + animation: view-in 240ms var(--ease-out) backwards; } @keyframes view-in { @@ -280,12 +267,6 @@ transition-duration: 0.01ms !important; } - ::view-transition-group(*), - ::view-transition-old(*), - ::view-transition-new(*) { - animation: none !important; - } - /* A still stretch would read as a broken line: light up all of it. */ .state-trace { stroke-dasharray: none; diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index 8b84ee40..c384935b 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -68,7 +68,7 @@ export function Workspace() { -
+
{runtimeError ? (
@@ -110,23 +110,31 @@ export function Workspace() { ); } +/** What the main pane shows; another bot, crew or page rises in. */ function Selection() { const page = useApp((state) => state.page); const crew = useApp((state) => (state.selectedCrewId ? state.crews[state.selectedCrewId] : null)); const bot = useApp((state) => (state.selectedBotId ? state.bots[state.selectedBotId] : null)); + let view: ReactNode; if (page === "questions") { - return ; - } - if (page === "search") { - return ; - } - if (crew && bot) { - return ; - } - if (crew) { - return ; + view = ; + } else if (page === "search") { + view = ; + } else if (crew && bot) { + view = ; + } else if (crew) { + view = ; + } else { + view = ; } - return ; + return ( +
+ {view} +
+ ); } function Breadcrumb() { diff --git a/app/src/shell/motionRoot.ts b/app/src/shell/motionRoot.ts index 05b5f39f..889a5be7 100644 --- a/app/src/shell/motionRoot.ts +++ b/app/src/shell/motionRoot.ts @@ -1,5 +1,5 @@ // "Less motion" in Settings (spec 15.3): marks the root, which -// `motion-less.css` and `reducedMotion()` follow. +// `motion-less.css` follows. import { useEffect } from "react"; import { prefs, usePref } from "./prefs"; diff --git a/app/src/store/app.ts b/app/src/store/app.ts index d9611f26..569440ec 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -23,7 +23,6 @@ import type { Task, TaskId, } from "../lib/protocol.gen"; -import { viewTransition } from "../ui/motion"; import { withQuestion } from "./questions"; import { loadMarked, loadSeen, loadSeenSince, seenActions, withReply } from "./seen"; @@ -149,7 +148,7 @@ export function createAppStore(api: BotloftApi): AppStore { const change = { selectedCrewId: bot.crewId, selectedBotId: botId, page: null, focus }; if (get().selectedBotId !== botId || get().page) { get().markSeen(botId); - viewTransition(() => set(change)); + set(change); } else if (focus) { set({ focus }); } @@ -180,16 +179,14 @@ export function createAppStore(api: BotloftApi): AppStore { selectCrew: (crewId) => { const { selectedCrewId, selectedBotId, page } = get(); if (selectedCrewId !== crewId || selectedBotId !== null || page) { - viewTransition(() => - set({ selectedCrewId: crewId, selectedBotId: null, page: null, focus: null }), - ); + set({ selectedCrewId: crewId, selectedBotId: null, page: null, focus: null }); } }, selectBot: (botId) => openBot(botId, null), openAt: (botId, itemId) => openBot(botId, { botId, itemId }), openPage: (page) => { if (get().page !== page) { - viewTransition(() => set({ page, selectedCrewId: null, selectedBotId: null })); + set({ page, selectedCrewId: null, selectedBotId: null }); } }, ...seenActions(get, set), diff --git a/app/src/ui/motion.test.tsx b/app/src/ui/motion.test.tsx index fe9218fb..347814d7 100644 --- a/app/src/ui/motion.test.tsx +++ b/app/src/ui/motion.test.tsx @@ -1,15 +1,11 @@ import { act, cleanup, render, screen, within } from "@testing-library/react"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { moodOf } from "../features/bots/BotAvatar"; import { FakeBotloft } from "../lib/fake"; import { crewOpened, openBot, renderApp } from "../test/app"; -import { SeenSince, useArrival, viewTransition } from "./motion"; +import { SeenSince, useArrival } from "./motion"; -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); - Reflect.deleteProperty(document, "startViewTransition"); -}); +afterEach(cleanup); describe("the mascot's mood", () => { test("follows what the bot does", () => { @@ -65,29 +61,22 @@ describe("arrivals", () => { }); }); -describe("view transitions", () => { - test("run the change at once where the webview has none", () => { - const update = vi.fn(); - viewTransition(update); - expect(update).toHaveBeenCalledOnce(); - }); - - test("go through the webview's transition when it has one", () => { - const start = vi.fn((run: () => void) => run()); - Object.assign(document, { startViewTransition: start }); - const update = vi.fn(); - viewTransition(update); - expect(start).toHaveBeenCalledOnce(); - expect(update).toHaveBeenCalledOnce(); - }); - - test("skip the transition when Windows asks for less motion", () => { - const start = vi.fn((run: () => void) => run()); - Object.assign(document, { startViewTransition: start }); - vi.stubGlobal("matchMedia", (query: string) => ({ matches: query.includes("reduce") })); - const update = vi.fn(); - viewTransition(update); - expect(start).not.toHaveBeenCalled(); - expect(update).toHaveBeenCalledOnce(); +describe("switching views", () => { + test("the new bot's pane rises in, and the window around it stays", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + for (const name of ["Scout", "Writer"]) { + fake.setBotState(fake.addBot(ops.id, name, "Works").id, "idle"); + } + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + const pane = () => screen.getByRole("main").lastElementChild as HTMLElement; + const scout = pane(); + expect(scout.className).toContain("pane-rise"); + openBot("Writer"); + await screen.findByLabelText("Message to Writer"); + expect(pane()).not.toBe(scout); + expect(pane().className).toContain("pane-rise"); }); }); diff --git a/app/src/ui/motion.ts b/app/src/ui/motion.ts index 5f05ee98..31681320 100644 --- a/app/src/ui/motion.ts +++ b/app/src/ui/motion.ts @@ -1,36 +1,7 @@ -// Helpers for motion (spec 15.3): switching views with a short transition, -// and telling what is new since the owner started looking, so only that -// animates in. +// Helpers for motion (spec 15.3): telling what is new since the owner +// started looking, so only that animates in. import { createContext, useContext } from "react"; -import { flushSync } from "react-dom"; - -/** - * Whether Windows asked for less motion ("Animation effects" off), or the - * owner did in Settings (`data-motion` on the root). - */ -export function reducedMotion(): boolean { - return ( - document.documentElement.dataset.motion === "less" || - (typeof window.matchMedia === "function" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches) - ); -} - -type WithTransitions = Document & { startViewTransition?(update: () => void): unknown }; - -/** - * Applies a change of view (another bot or crew) as a short transition of - * the main pane, where the webview can; otherwise at once. - */ -export function viewTransition(update: () => void): void { - const doc = document as WithTransitions; - if (typeof doc.startViewTransition !== "function" || reducedMotion()) { - update(); - return; - } - doc.startViewTransition(() => flushSync(update)); -} /** When the app opened: a bot created after it is new to the owner. */ export const APP_OPENED = Date.now(); diff --git a/docs/spec.md b/docs/spec.md index aaba5928..c28f578b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -874,7 +874,7 @@ Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animaçõ - **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. - **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á. +- **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). - **Superfícies:** menus e seletores abrem a partir de onde estão presos; diálogos aparecem com o fundo escurecendo e, fechados de qualquer jeito (X, Esc, Cancelar, Salvar), somem esmaecendo e encolhendo um pouco (150 ms); avisos entram deslizando; `
` abre deslizando; a linha da aba selecionada desliza até a nova; as barras de uso enchem ao abrir; botões afundam um pouco ao clicar; o mascote da tela de boas-vindas e do chat vazio flutua. No chat: