Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions app/src/features/account/prefs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
Expand Down
6 changes: 0 additions & 6 deletions app/src/motion-less.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 6 additions & 25 deletions app/src/motion.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
32 changes: 20 additions & 12 deletions app/src/shell/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function Workspace() {
<SidebarSlot>
<Sidebar />
</SidebarSlot>
<main className="flex min-w-0 flex-1 flex-col [view-transition-name:main-pane]">
<main className="flex min-w-0 flex-1 flex-col">
{runtimeError ? (
<div className="border-line border-b p-3">
<Callout tone="danger" title={t.shell.botsCantStart}>
Expand Down Expand Up @@ -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 <QuestionBox />;
}
if (page === "search") {
return <SearchPage />;
}
if (crew && bot) {
return <BotView key={bot.id} bot={bot} crew={crew} />;
}
if (crew) {
return <CrewView key={crew.id} crew={crew} />;
view = <QuestionBox />;
} else if (page === "search") {
view = <SearchPage />;
} else if (crew && bot) {
view = <BotView bot={bot} crew={crew} />;
} else if (crew) {
view = <CrewView crew={crew} />;
} else {
view = <CrewsOverview />;
}
return <CrewsOverview />;
return (
<div
key={page ?? bot?.id ?? crew?.id ?? "crews"}
className="pane-rise flex min-h-0 flex-1 flex-col"
>
{view}
</div>
);
}

function Breadcrumb() {
Expand Down
2 changes: 1 addition & 1 deletion app/src/shell/motionRoot.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
9 changes: 3 additions & 6 deletions app/src/store/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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),
Expand Down
51 changes: 20 additions & 31 deletions app/src/ui/motion.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
33 changes: 2 additions & 31 deletions app/src/ui/motion.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; `<details>` 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:
Expand Down
Loading