From cb0e9907f54f7e75b3328d42216a082cc16f0f6f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Nov 2026 09:08:33 +0100 Subject: [PATCH] fix(app): keep the setup window mounted when the theme changes The shell's appearance boundary remounts its dialog hosts whenever a theme color changes, so picking a theme in setup, or a system light/dark switch, replaced the open setup window and replayed its fade. Setup draws colors through CSS variables and themed props, which update in place, and changes no font size, so it now sits outside that remount. The dialog enter fade is skipped with reduced motion, and its style is typed for CI, which typechecks without the generated expo-env.d.ts. (cherry picked from commit b24c6eacf8693985d914f2b5d61b3df05feb20b6) --- packages/app/e2e/browser/onboarding.spec.ts | 52 +++++++++++++++++++ packages/app/src/app/_layout.tsx | 5 +- .../src/components/adaptive-modal-sheet.tsx | 6 ++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/browser/onboarding.spec.ts b/packages/app/e2e/browser/onboarding.spec.ts index 7ba2b51b89..cf9ab06442 100644 --- a/packages/app/e2e/browser/onboarding.spec.ts +++ b/packages/app/e2e/browser/onboarding.spec.ts @@ -120,6 +120,58 @@ test("a new user goes from the loading screen straight into setup", async ({ pag } }); +test("with reduced motion, setup appears without fading in", async ({ page }) => { + const host = await startSetupHost("reduced-motion"); + + try { + await page.emulateMedia({ reducedMotion: "reduce" }); + await recordFirstLaunch(page); + await openAppAsNewUser(page, host); + await expectSetupStep(page, "Welcome to Paseo"); + + expect((await readFirstLaunch(page)).setupOpacityOnArrival).toBe(1); + } finally { + await host.close(); + } +}); + +test("changing the theme keeps the setup window in place on every step", async ({ page }) => { + const host = await startSetupHost("theme-change"); + + try { + await openAppAsNewUser(page, host); + await expectSetupStep(page, "Welcome to Paseo"); + const setupWindow = await setupModal(page).elementHandle(); + + for (const title of [ + "Welcome to Paseo", + "Your agents", + "Add your projects", + "Let agents use Paseo", + "Take your agents with you", + ]) { + if (title !== "Welcome to Paseo") await continueSetup(page); + await expectSetupStep(page, title); + const heading = setupModal(page).getByRole("heading", { name: title }); + const headingNode = await heading.elementHandle(); + const lightColor = await heading.evaluate((node) => getComputedStyle(node).color); + await page.emulateMedia({ colorScheme: "dark" }); + await expect(heading).not.toHaveCSS("color", lightColor); + await page.emulateMedia({ colorScheme: "light" }); + await expect(heading).toHaveCSS("color", lightColor); + expect(await setupWindow?.evaluate((node) => node.isConnected), title).toBe(true); + expect(await headingNode?.evaluate((node) => node.isConnected), title).toBe(true); + } + + await goToSetupStep(page, "Appearance"); + await pickTheme(page, "Midnight"); + await pickTheme(page, "Light"); + expect(await setupWindow?.evaluate((node) => node.isConnected)).toBe(true); + } finally { + await host.close(); + } +}); + test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => { const host = await startSetupHost("slow-workspaces"); const gate = await installDaemonWebSocketGate(page, { port: host.port }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 996cbed429..2981784775 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -524,7 +524,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon - @@ -535,6 +534,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + {/* Setup changes the theme while it is open, so it stays out of the remount above. Its + colors are CSS variables and themed props, which update in place, and setup changes no + font size. */} + ); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index bf452f432a..7f19b56748 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -241,16 +241,18 @@ const WEB_ENTER_ANIMATION_NAME = "paseo-modal-enter"; // The desktop card fades in; the dim lands at once, so the screen behind is never seen undimmed // while the dialog arrives. A CSS animation runs from the card's first paint, so a dialog that opens -// on its own, like first-run setup, never pops in. Closing fades the whole overlay. +// on its own, like first-run setup, never pops in. Closing fades the whole overlay. With reduced +// motion the keyframes are never defined, so the card appears at once. function ensureWebEnterKeyframes() { if (!isWeb || typeof document === "undefined") return; if (document.getElementById(WEB_ENTER_ANIMATION_NAME)) return; const style = document.createElement("style"); style.id = WEB_ENTER_ANIMATION_NAME; - style.textContent = `@keyframes ${WEB_ENTER_ANIMATION_NAME} { from { opacity: 0; } to { opacity: 1; } }`; + style.textContent = `@media (prefers-reduced-motion: no-preference) { @keyframes ${WEB_ENTER_ANIMATION_NAME} { from { opacity: 0; } to { opacity: 1; } } }`; document.head.appendChild(style); } ensureWebEnterKeyframes(); +// The CSS animation props are not part of ViewStyle without the generated expo-env.d.ts. const WEB_ENTER_STYLE = { animationName: WEB_ENTER_ANIMATION_NAME, animationDuration: `${WEB_ENTER_DURATION_MS}ms`,