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: