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: