diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index 4e37173..ff37c10 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -224,4 +224,29 @@ run("navigation motion (headless Chrome)", () => { await until(() => chrome.eval(`document.querySelector(".stage canvas[data-front]").dataset.page === "3"`), "slide 3 drawn"); expect(await text(".deckbar .count")).toBe("3 / 3"); }, 30_000); + + test("progress fills, the toast and the course menu answer with motion; Escape still closes at once", async () => { + await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title"); + const status = await chrome.eval(`fetch("/api/auth/sign-up/email", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "motion@example.org", name: "Motion", password: "correct horse battery" }) }).then((r) => r.status)`); + expect(status).toBe(200); + await open("/c/sample/m1-intro", ".stc-dock button[aria-pressed]:not([disabled])"); + await click(".stc-dock button[aria-pressed]"); + await until(async () => (await m()).trans.includes("width@i"), "progress fill transition"); + + // The toast rises in, then fades out before it is removed. + await open("/c/sample/m2-ex-add", ".stc-seg button"); + await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`); + await until(async () => (await m()).anims.includes("stc-enter@lr lr-toast"), "toast enter"); + await until(async () => (await m()).anims.includes("stc-fade-out@lr lr-toast is-leaving"), "toast leave", 5000); + await until(async () => !(await exists(".lr-toast")), "toast removed", 2000); + + // The course menu drops in; Escape closes it in the same task. + await reset(); + await click(".lr-switch-btn"); + await until(async () => (await m()).anims.includes("stc-drop@lr-menu"), "menu enter"); + await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + expect(await exists(".lr-menu")).toBe(false); + }, 40_000); }); diff --git a/packages/feature-learner/src/web/api.ts b/packages/feature-learner/src/web/api.ts index 034b514..1b5dc13 100644 --- a/packages/feature-learner/src/web/api.ts +++ b/packages/feature-learner/src/web/api.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import type { AuthUser } from "@stc/contracts"; +import { reducedMotion } from "@stc/ui"; import type { CoursePayload, LibraryPayload } from "../shared/types"; import { learnerCss } from "./styles"; @@ -154,14 +155,22 @@ export function clearCourseCache(): void { courseCache.clear(); courseVersion.se /* ---------- toast ---------- */ -const toastStore = store(null); +export interface ToastState { text: string; leaving: boolean } +const toastStore = store(null); let toastTimer: ReturnType | undefined; +/** How long the toast takes to fade out (--t-exit). */ +const TOAST_EXIT_MS = 120; export function toast(msg: string): void { - toastStore.set(msg); + toastStore.set({ text: msg, leaving: false }); clearTimeout(toastTimer); - toastTimer = setTimeout(() => toastStore.set(null), 2600); + toastTimer = setTimeout(() => { + const t = toastStore.get(); + if (!t || reducedMotion()) { toastStore.set(null); return; } + toastStore.set({ ...t, leaving: true }); + toastTimer = setTimeout(() => toastStore.set(null), TOAST_EXIT_MS); + }, 2600); } -export function useToast(): string | null { return useStore(toastStore); } +export function useToast(): ToastState | null { return useStore(toastStore); } /* ---------- formatting ---------- */ diff --git a/packages/feature-learner/src/web/shell/HeaderExtras.tsx b/packages/feature-learner/src/web/shell/HeaderExtras.tsx index f0cdc9a..b90e7e6 100644 --- a/packages/feature-learner/src/web/shell/HeaderExtras.tsx +++ b/packages/feature-learner/src/web/shell/HeaderExtras.tsx @@ -37,7 +37,7 @@ export function HeaderExtras({ course, lookMode, outline }: { , host, )} - {msg ?
{msg}
: null} + {msg ?
{msg.text}
: null} ); } diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts index 6eadd37..d1861ca 100644 --- a/packages/feature-learner/src/web/styles.ts +++ b/packages/feature-learner/src/web/styles.ts @@ -342,6 +342,11 @@ export const learnerCss = String.raw` .lr .stage canvas[data-front][data-dir="next"] { animation: stc-slide-next var(--t-enter) var(--ease-out) backwards; } .lr .stage canvas[data-front][data-dir="prev"] { animation: stc-slide-prev var(--t-enter) var(--ease-out) backwards; } .lr .stage canvas[data-leaving] { animation: stc-hold-out var(--t-enter) linear both; } +/* overlays answer the action that opened them; closing is instant except for the toast's short fade */ +.lr-menu { animation: stc-drop var(--t-fast) var(--ease-out) backwards; } +.lr-shell.side-open .lr-scrim { animation: stc-fade-in var(--t-fast) var(--ease-out) backwards; } +.lr-toast { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } +.lr-toast.is-leaving { animation: stc-fade-out var(--t-exit) var(--ease-out) both; } /* a module list the user opens */ .lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } /* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */