Skip to content

Commit 00a3872

Browse files
authored
Merge pull request #144 from httpsphl/fix/pane-rise
fix(app): rise a new pane in without a view transition
2 parents 742cc69 + 867e4a5 commit 00a3872

9 files changed

Lines changed: 54 additions & 116 deletions

File tree

‎app/src/features/account/prefs.test.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { afterEach, describe, expect, test } from "vitest";
33
import { FakeBotloft } from "../../lib/fake";
44
import { prefs, resetPrefs } from "../../shell/prefs";
55
import { crewOpened, openBot, renderApp } from "../../test/app";
6-
import { reducedMotion } from "../../ui/motion";
76

87
afterEach(() => {
98
cleanup();
@@ -68,10 +67,9 @@ describe("the app's own choices", () => {
6867
test("less motion stills the window", async () => {
6968
renderApp(new FakeBotloft());
7069
const dialog = await openSettings("Appearance");
71-
expect(reducedMotion()).toBe(false);
70+
expect(document.documentElement.dataset.motion).toBeUndefined();
7271
fireEvent.click(within(dialog).getByRole("switch", { name: "Less motion" }));
7372
expect(document.documentElement.dataset.motion).toBe("less");
74-
expect(reducedMotion()).toBe(true);
7573
fireEvent.click(within(dialog).getByRole("switch", { name: "Less motion" }));
7674
expect(document.documentElement.dataset.motion).toBeUndefined();
7775
});

‎app/src/motion-less.css‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,6 @@
1212
transition-duration: 0.01ms !important;
1313
}
1414

15-
:root[data-motion="less"]::view-transition-group(*),
16-
:root[data-motion="less"]::view-transition-old(*),
17-
:root[data-motion="less"]::view-transition-new(*) {
18-
animation: none !important;
19-
}
20-
2115
/* A still stretch would read as a broken line: light up all of it. */
2216
:root[data-motion="less"] .state-trace {
2317
stroke-dasharray: none;

‎app/src/motion.css‎

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -241,25 +241,12 @@
241241
}
242242
}
243243

244-
/* Switching bots or crews: the main pane fades out and the new one rises
245-
in; the rest of the window stays put (ui/motion.ts). */
246-
::view-transition-old(root),
247-
::view-transition-new(root) {
248-
animation: none;
249-
}
250-
251-
::view-transition-old(main-pane) {
252-
animation: 110ms ease-out both view-out;
253-
}
254-
255-
::view-transition-new(main-pane) {
256-
animation: 240ms var(--ease-out) both view-in;
257-
}
258-
259-
@keyframes view-out {
260-
to {
261-
opacity: 0;
262-
}
244+
/* Switching bots or crews: the new main pane rises in; the rest of the
245+
window stays put (shell/Workspace.tsx). A plain animation, not a view
246+
transition: as one ended, WebView2 painted a mascot without its clip
247+
for a few frames, an orange square. */
248+
.pane-rise {
249+
animation: view-in 240ms var(--ease-out) backwards;
263250
}
264251

265252
@keyframes view-in {
@@ -280,12 +267,6 @@
280267
transition-duration: 0.01ms !important;
281268
}
282269

283-
::view-transition-group(*),
284-
::view-transition-old(*),
285-
::view-transition-new(*) {
286-
animation: none !important;
287-
}
288-
289270
/* A still stretch would read as a broken line: light up all of it. */
290271
.state-trace {
291272
stroke-dasharray: none;

‎app/src/shell/Workspace.tsx‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export function Workspace() {
6868
<SidebarSlot>
6969
<Sidebar />
7070
</SidebarSlot>
71-
<main className="flex min-w-0 flex-1 flex-col [view-transition-name:main-pane]">
71+
<main className="flex min-w-0 flex-1 flex-col">
7272
{runtimeError ? (
7373
<div className="border-line border-b p-3">
7474
<Callout tone="danger" title={t.shell.botsCantStart}>
@@ -110,23 +110,31 @@ export function Workspace() {
110110
);
111111
}
112112

113+
/** What the main pane shows; another bot, crew or page rises in. */
113114
function Selection() {
114115
const page = useApp((state) => state.page);
115116
const crew = useApp((state) => (state.selectedCrewId ? state.crews[state.selectedCrewId] : null));
116117
const bot = useApp((state) => (state.selectedBotId ? state.bots[state.selectedBotId] : null));
118+
let view: ReactNode;
117119
if (page === "questions") {
118-
return <QuestionBox />;
119-
}
120-
if (page === "search") {
121-
return <SearchPage />;
122-
}
123-
if (crew && bot) {
124-
return <BotView key={bot.id} bot={bot} crew={crew} />;
125-
}
126-
if (crew) {
127-
return <CrewView key={crew.id} crew={crew} />;
120+
view = <QuestionBox />;
121+
} else if (page === "search") {
122+
view = <SearchPage />;
123+
} else if (crew && bot) {
124+
view = <BotView bot={bot} crew={crew} />;
125+
} else if (crew) {
126+
view = <CrewView crew={crew} />;
127+
} else {
128+
view = <CrewsOverview />;
128129
}
129-
return <CrewsOverview />;
130+
return (
131+
<div
132+
key={page ?? bot?.id ?? crew?.id ?? "crews"}
133+
className="pane-rise flex min-h-0 flex-1 flex-col"
134+
>
135+
{view}
136+
</div>
137+
);
130138
}
131139

132140
function Breadcrumb() {

‎app/src/shell/motionRoot.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// "Less motion" in Settings (spec 15.3): marks the root, which
2-
// `motion-less.css` and `reducedMotion()` follow.
2+
// `motion-less.css` follows.
33

44
import { useEffect } from "react";
55
import { prefs, usePref } from "./prefs";

‎app/src/store/app.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import type {
2323
Task,
2424
TaskId,
2525
} from "../lib/protocol.gen";
26-
import { viewTransition } from "../ui/motion";
2726
import { withQuestion } from "./questions";
2827
import { loadMarked, loadSeen, loadSeenSince, seenActions, withReply } from "./seen";
2928

@@ -149,7 +148,7 @@ export function createAppStore(api: BotloftApi): AppStore {
149148
const change = { selectedCrewId: bot.crewId, selectedBotId: botId, page: null, focus };
150149
if (get().selectedBotId !== botId || get().page) {
151150
get().markSeen(botId);
152-
viewTransition(() => set(change));
151+
set(change);
153152
} else if (focus) {
154153
set({ focus });
155154
}
@@ -180,16 +179,14 @@ export function createAppStore(api: BotloftApi): AppStore {
180179
selectCrew: (crewId) => {
181180
const { selectedCrewId, selectedBotId, page } = get();
182181
if (selectedCrewId !== crewId || selectedBotId !== null || page) {
183-
viewTransition(() =>
184-
set({ selectedCrewId: crewId, selectedBotId: null, page: null, focus: null }),
185-
);
182+
set({ selectedCrewId: crewId, selectedBotId: null, page: null, focus: null });
186183
}
187184
},
188185
selectBot: (botId) => openBot(botId, null),
189186
openAt: (botId, itemId) => openBot(botId, { botId, itemId }),
190187
openPage: (page) => {
191188
if (get().page !== page) {
192-
viewTransition(() => set({ page, selectedCrewId: null, selectedBotId: null }));
189+
set({ page, selectedCrewId: null, selectedBotId: null });
193190
}
194191
},
195192
...seenActions(get, set),

‎app/src/ui/motion.test.tsx‎

Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
import { act, cleanup, render, screen, within } from "@testing-library/react";
2-
import { afterEach, describe, expect, test, vi } from "vitest";
2+
import { afterEach, describe, expect, test } from "vitest";
33
import { moodOf } from "../features/bots/BotAvatar";
44
import { FakeBotloft } from "../lib/fake";
55
import { crewOpened, openBot, renderApp } from "../test/app";
6-
import { SeenSince, useArrival, viewTransition } from "./motion";
6+
import { SeenSince, useArrival } from "./motion";
77

8-
afterEach(() => {
9-
cleanup();
10-
vi.unstubAllGlobals();
11-
Reflect.deleteProperty(document, "startViewTransition");
12-
});
8+
afterEach(cleanup);
139

1410
describe("the mascot's mood", () => {
1511
test("follows what the bot does", () => {
@@ -65,29 +61,22 @@ describe("arrivals", () => {
6561
});
6662
});
6763

68-
describe("view transitions", () => {
69-
test("run the change at once where the webview has none", () => {
70-
const update = vi.fn();
71-
viewTransition(update);
72-
expect(update).toHaveBeenCalledOnce();
73-
});
74-
75-
test("go through the webview's transition when it has one", () => {
76-
const start = vi.fn((run: () => void) => run());
77-
Object.assign(document, { startViewTransition: start });
78-
const update = vi.fn();
79-
viewTransition(update);
80-
expect(start).toHaveBeenCalledOnce();
81-
expect(update).toHaveBeenCalledOnce();
82-
});
83-
84-
test("skip the transition when Windows asks for less motion", () => {
85-
const start = vi.fn((run: () => void) => run());
86-
Object.assign(document, { startViewTransition: start });
87-
vi.stubGlobal("matchMedia", (query: string) => ({ matches: query.includes("reduce") }));
88-
const update = vi.fn();
89-
viewTransition(update);
90-
expect(start).not.toHaveBeenCalled();
91-
expect(update).toHaveBeenCalledOnce();
64+
describe("switching views", () => {
65+
test("the new bot's pane rises in, and the window around it stays", async () => {
66+
const fake = new FakeBotloft();
67+
const ops = fake.addCrew("Ops");
68+
for (const name of ["Scout", "Writer"]) {
69+
fake.setBotState(fake.addBot(ops.id, name, "Works").id, "idle");
70+
}
71+
renderApp(fake);
72+
await crewOpened("Ops");
73+
openBot("Scout");
74+
const pane = () => screen.getByRole("main").lastElementChild as HTMLElement;
75+
const scout = pane();
76+
expect(scout.className).toContain("pane-rise");
77+
openBot("Writer");
78+
await screen.findByLabelText("Message to Writer");
79+
expect(pane()).not.toBe(scout);
80+
expect(pane().className).toContain("pane-rise");
9281
});
9382
});

‎app/src/ui/motion.ts‎

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,7 @@
1-
// Helpers for motion (spec 15.3): switching views with a short transition,
2-
// and telling what is new since the owner started looking, so only that
3-
// animates in.
1+
// Helpers for motion (spec 15.3): telling what is new since the owner
2+
// started looking, so only that animates in.
43

54
import { createContext, useContext } from "react";
6-
import { flushSync } from "react-dom";
7-
8-
/**
9-
* Whether Windows asked for less motion ("Animation effects" off), or the
10-
* owner did in Settings (`data-motion` on the root).
11-
*/
12-
export function reducedMotion(): boolean {
13-
return (
14-
document.documentElement.dataset.motion === "less" ||
15-
(typeof window.matchMedia === "function" &&
16-
window.matchMedia("(prefers-reduced-motion: reduce)").matches)
17-
);
18-
}
19-
20-
type WithTransitions = Document & { startViewTransition?(update: () => void): unknown };
21-
22-
/**
23-
* Applies a change of view (another bot or crew) as a short transition of
24-
* the main pane, where the webview can; otherwise at once.
25-
*/
26-
export function viewTransition(update: () => void): void {
27-
const doc = document as WithTransitions;
28-
if (typeof doc.startViewTransition !== "function" || reducedMotion()) {
29-
update();
30-
return;
31-
}
32-
doc.startViewTransition(() => flushSync(update));
33-
}
345

356
/** When the app opened: a bot created after it is new to the owner. */
367
export const APP_OPENED = Date.now();

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -874,7 +874,7 @@ Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animaçõ
874874
- **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.
875875
- **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.
876876
- **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.
877-
- **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á.
877+
- **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).
878878
- **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.
879879

880880
No chat:

0 commit comments

Comments
 (0)