From 156be3addc1564c0af4e71bd9ac1dbe8f2222c1d Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Mon, 26 Oct 2026 20:25:22 -0700 Subject: [PATCH] Give OpenDuet's sheets, swipe, menus and swaps a real entrance and exit Every always-open sheet (new task, new channel, a task, a question) was mounted open and closed by its parent unmounting it, so Base UI never played its entrance or its exit. useSheetPresence opens them a frame after mount and keeps them mounted through the exit before telling the parent. The hand-off swipe fades the card's content on the blur's ramp, so the dark Start pill no longer smears against the clipped edge; it is thrown at a least speed and fades as it reaches the edge of what shows it; and Up next closes a swiped card's place as soon as it lands. The drawer slides in from fully off-screen with no panel fade. Make's fan folds back into the + instead of vanishing. The tab capsule slides and the Tasks badge pops in with its number turning over. Start becomes Started, an approval becomes its decided line, and the composer's glyph swaps, each with a short blur. Menus, tooltips and dialogs move on the tokens, a bare transition utility now takes the tap duration and the strong ease-out, hover states that snapped transition, and the one transition-all is gone. --- .../src/components/agents/new-bot-sheet.tsx | 3 +- .../components/approvals/approval-card.tsx | 102 ++++++++--- .../components/approvals/inline-approval.tsx | 9 +- .../components/channels/composer/composer.tsx | 68 +++++-- .../channels/composer/dictation-controls.tsx | 2 +- .../channels/composer/voice-sheet.tsx | 8 +- .../src/components/computer/computer-view.tsx | 2 +- .../components/duet/morning-brief-card.tsx | 43 ++++- openbot/app/src/components/duet/page-card.tsx | 2 +- .../app/src/components/duet/page-sheet.tsx | 173 +++++++++++------- .../src/components/duet/question-sheet.tsx | 17 +- openbot/app/src/components/duet/task-card.tsx | 6 +- .../app/src/components/duet/task-sheet.tsx | 15 +- .../components/onboarding/capability-demo.tsx | 2 + .../onboarding/onboarding-frame.tsx | 2 +- .../onboarding/onboarding-wizard.tsx | 3 +- .../src/components/shell/activity-screen.tsx | 4 +- .../src/components/shell/channel-screen.tsx | 2 +- .../src/components/shell/channel-sheet.tsx | 30 ++- .../app/src/components/shell/make-screen.tsx | 43 ++++- .../app/src/components/shell/menu-sheet.tsx | 4 +- .../src/components/shell/new-task-sheet.tsx | 24 ++- .../src/components/shell/suggestion-card.tsx | 2 +- .../src/components/shell/swipe-to-answer.tsx | 149 +++++++++++---- openbot/app/src/components/shell/tab-bar.tsx | 123 +++++++++++-- .../app/src/components/shell/tasks-active.tsx | 4 +- .../app/src/components/shell/tasks-screen.tsx | 5 +- .../src/components/shell/tasks-up-next.tsx | 106 ++++++++--- openbot/app/src/components/ui/button.tsx | 2 +- openbot/app/src/components/ui/checkbox.tsx | 2 +- openbot/app/src/components/ui/combobox.tsx | 2 +- .../app/src/components/ui/context-menu.tsx | 2 +- openbot/app/src/components/ui/dialog.tsx | 7 +- .../app/src/components/ui/dropdown-menu.tsx | 4 +- openbot/app/src/components/ui/item.tsx | 2 +- .../src/components/ui/message-scroller.tsx | 2 +- openbot/app/src/components/ui/select.tsx | 2 +- openbot/app/src/components/ui/sheet.tsx | 17 +- openbot/app/src/components/ui/sidebar.tsx | 8 +- openbot/app/src/components/ui/switch.tsx | 4 +- openbot/app/src/components/ui/tooltip.tsx | 2 +- openbot/app/src/hooks/use-sheet-presence.ts | 52 ++++++ .../app/src/lib/copilot/reply-chips-tool.tsx | 3 +- openbot/app/src/lib/motion.ts | 22 +++ openbot/app/src/styles.css | 43 ++++- openbot/app/tests/app-shell.test.tsx | 17 ++ openbot/app/tests/channel-sheet.test.tsx | 11 +- openbot/app/tests/make-screen.test.tsx | 10 +- openbot/app/tests/motion-swaps.test.tsx | 154 ++++++++++++++++ openbot/app/tests/motion-tokens.test.ts | 64 +++++++ openbot/app/tests/sheet-presence.test.tsx | 118 ++++++++++++ openbot/app/tests/swipe-to-answer.test.tsx | 17 +- openbot/app/tests/tasks-screen.test.tsx | 44 +++++ 53 files changed, 1295 insertions(+), 269 deletions(-) create mode 100644 openbot/app/src/hooks/use-sheet-presence.ts create mode 100644 openbot/app/tests/motion-swaps.test.tsx create mode 100644 openbot/app/tests/motion-tokens.test.ts create mode 100644 openbot/app/tests/sheet-presence.test.tsx diff --git a/openbot/app/src/components/agents/new-bot-sheet.tsx b/openbot/app/src/components/agents/new-bot-sheet.tsx index a8535a63..fb0bd6e6 100644 --- a/openbot/app/src/components/agents/new-bot-sheet.tsx +++ b/openbot/app/src/components/agents/new-bot-sheet.tsx @@ -41,6 +41,7 @@ import { } from "@/lib/agents/queries"; import { isComposing } from "@/lib/composing"; import { appConfig } from "@/lib/generated/application-config"; +import { DURATION, EASE_OUT } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { queryClient } from "@/query-client"; @@ -332,7 +333,7 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { className="mt-6" initial={{ opacity: 0, x: 24 * direction }} key={step} - transition={{ duration: 0.2, ease: "easeOut" }} + transition={{ duration: DURATION.swap, ease: EASE_OUT }} > {step === "about" ? ( void; /** Only the title, the outcome and who decided: a decided card left in a conversation. */ compact?: boolean; + /** The request was decided while it was on screen, in a card this one replaces. */ + decidedNow?: boolean; className?: string; }) { const view = describeApproval(request); const waiting = request.status === "pending"; + // Drawn waiting at some point, so a decision that comes in is one the person saw being made. + const [sawWaiting, setSawWaiting] = useState(waiting); + if (waiting && !sawWaiting) setSawWaiting(true); + const arrives = decidedNow || sawWaiting; + const reduced = useReducedMotionPreference(); const handOff = request.action.policy?.behaviour === "hand_off"; const outcome = approvalOutcome(request); const receipt = waiting ? null : decisionReceipt(request); @@ -94,7 +111,7 @@ export function ApprovalCard({ raised role={waiting ? "region" : undefined} > -
+

@@ -104,14 +121,34 @@ export function ApprovalCard({

{caption}

) : null}
- {outcome ? ( - - {outcome.label} - - ) : null} + + {outcome ? ( + + + {outcome.label} + + + ) : null} +
{compact ? null : ( @@ -154,7 +191,7 @@ export function ApprovalCard({
{handOff ? null : ( ) : callAction ? ( ) : voiceAction ? ( ) : ( ); @@ -1813,3 +1825,33 @@ function withMediaTypeOnly(file: File): File { type: mediaType, }); } + +/** + * The glyph on the composer's one round button, which stands for four things in turn: Send, Stop + * while the Bot answers, a voice call, and speaking a message. When what it stands for changes, as + * it does the moment a message is sent and Stop takes Send's place, the old glyph shrinks into a + * blur as the new one grows out of it, both at once, in place. The button itself stays put. + * + * Drawn as it is on the first paint; only a change swaps. With reduced motion the two cross-fade. + */ +function SwapGlyph({ name, children }: { name: string; children: ReactNode }) { + const reduced = useReducedMotionPreference(); + const away = reduced + ? { opacity: 0 } + : { opacity: 0, scale: 0.25, filter: "blur(2px)" }; + return ( + + + {children} + + + ); +} diff --git a/openbot/app/src/components/channels/composer/dictation-controls.tsx b/openbot/app/src/components/channels/composer/dictation-controls.tsx index ccaab93c..40970788 100644 --- a/openbot/app/src/components/channels/composer/dictation-controls.tsx +++ b/openbot/app/src/components/channels/composer/dictation-controls.tsx @@ -75,7 +75,7 @@ export function DictationSurface({ aria-hidden={recording || undefined} inert={recording} className={cn( - "min-w-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transform-none motion-reduce:transition-none", + "min-w-0 transition-[opacity,transform] duration-[var(--duet-dur-swap)] motion-reduce:transform-none motion-reduce:transition-none", recording ? "pointer-events-none -translate-y-1 opacity-0" : "translate-y-0 opacity-100", diff --git a/openbot/app/src/components/channels/composer/voice-sheet.tsx b/openbot/app/src/components/channels/composer/voice-sheet.tsx index 8b0aa642..b3308e9c 100644 --- a/openbot/app/src/components/channels/composer/voice-sheet.tsx +++ b/openbot/app/src/components/channels/composer/voice-sheet.tsx @@ -57,7 +57,7 @@ export function VoiceSheet({ aria-modal="true" className={cn( "duet-veil duet-face group fixed inset-0 z-50 flex flex-col overflow-y-auto text-duet-ink outline-none", - "transition-opacity duration-200 ease-out data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none", + "transition-opacity duration-[var(--duet-dur-enter)] data-ending-style:duration-[var(--duet-dur-swap)] data-ending-style:opacity-0 data-starting-style:opacity-0", )} // The sheet itself takes focus, not ×: a stray Enter or Space should not throw the words away. initialFocus={popup} @@ -74,7 +74,7 @@ export function VoiceSheet({ -
+
); } diff --git a/openbot/app/src/components/duet/page-card.tsx b/openbot/app/src/components/duet/page-card.tsx index a76c83e9..fa277b5d 100644 --- a/openbot/app/src/components/duet/page-card.tsx +++ b/openbot/app/src/components/duet/page-card.tsx @@ -62,7 +62,7 @@ export function PageCard({ children: ( (null); + const reduced = useReducedMotionPreference(); const morph = useSheetMorph(pageId, onClose, (summary, size) => ( )); @@ -121,7 +125,7 @@ export function PageSheet({ className={cn( "duet-face group fixed inset-0 z-50 flex flex-col bg-duet-surface text-duet-ink outline-none", // The veil fades in and out with reduced motion too: a fade is the reduced transition. - "transition-opacity duration-200 ease-out data-ending-style:opacity-0 data-starting-style:opacity-0", + "transition-opacity duration-[var(--duet-dur-swap)] data-ending-style:opacity-0 data-starting-style:opacity-0", morph.fading && "opacity-0", )} data-closing={morph.fading || undefined} @@ -138,9 +142,11 @@ export function PageSheet({ className={cn( // A size container, so the header below can ask how much room it has. "@container flex min-h-0 flex-1 flex-col overflow-hidden motion-reduce:transition-none", + // Morphing, the card fades in under the stand-in as it leaves, on the morph's own + // short hand-over (page-morph.tsx), not a token; otherwise it rises like a sheet. morph.morphing ? "transition-opacity duration-150 ease-out" - : "transition-transform duration-300 ease-out motion-safe:group-data-starting-style:translate-y-6", + : "transition-transform duration-[var(--duet-dur-sheet)] motion-safe:group-data-starting-style:translate-y-6", morph.hidden && "opacity-0", )} data-morph-hidden={morph.hidden || undefined} @@ -176,73 +182,110 @@ export function PageSheet({ > … - {actions ? ( -
- - - - -
- ) : null} + + + + + + ) : null} +
{notice ? ( -

+

{notice}

) : null} @@ -430,7 +473,7 @@ export function SlideDeck({ onPointerUp={onPointerUp} >
{slides.map((slide, at) => ( diff --git a/openbot/app/src/components/duet/question-sheet.tsx b/openbot/app/src/components/duet/question-sheet.tsx index 061dc2ed..e084976d 100644 --- a/openbot/app/src/components/duet/question-sheet.tsx +++ b/openbot/app/src/components/duet/question-sheet.tsx @@ -9,6 +9,7 @@ import { approvalInboxOptions, waitingOnYou, } from "@/lib/approvals"; +import { useSheetPresence } from "@/hooks/use-sheet-presence"; import { cn } from "@/lib/utils"; import { queryClient } from "@/query-client"; import { DuetIcon } from "./icons"; @@ -34,6 +35,9 @@ export function QuestionSheet({ const answer = useMutation(answerPersonQuestionMutationOptions(queryClient)); const [draft, setDraft] = useState(""); const popup = useRef(null); + // Fades out and sinks a little before the route closes it (use-sheet-presence.ts). + const presence = useSheetPresence(); + const close = () => presence.close(onClose); const question = waitingOnYou(inbox.data).questions.find( (item) => item.id === questionId, ); @@ -44,33 +48,34 @@ export function QuestionSheet({ const send = () => { const response = draft.trim(); if (!question || !response || answer.isPending) return; - answer.mutate({ id: question.id, response }, { onSuccess: onClose }); + answer.mutate({ id: question.id, response }, { onSuccess: close }); }; return ( { - if (!next) onClose(); + if (!next) close(); }} - open + onOpenChangeComplete={presence.onOpenChangeComplete} + open={presence.open} >
-
+
{searching ? ( -
+ // Fades in under the switch as it opens, with a short drop that reduced motion leaves out. +
+ + + + ); +} + +const wait = (ms: number) => + act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +test("mounted by its parent, it opens a frame later, so its entrance has a closed state to come from", async () => { + // Base UI only plays `data-starting-style` when `open` turns true after the dialog has mounted. + const seen: boolean[] = []; + function Probe() { + seen.push(useSheetPresence().open); + return null; + } + render(); + expect(seen[0]).toBe(false); + expect(seen.at(-1)).toBe(true); + const view = render( {}} />); + expect(await view.findByRole("dialog", { name: "New task" })).toBeTruthy(); +}); + +/** + * The sheet's exit as the browser would run it: one animation on it, finished when `end` is called, + * or never. This DOM runs no CSS, so without it every exit would be over before it began. + */ +function exiting() { + let end = () => {}; + const finished = new Promise((resolve) => { + end = resolve; + }); + const before = HTMLElement.prototype.getAnimations; + HTMLElement.prototype.getAnimations = () => + [{ finished, pending: false, playState: "running" }] as never; + return { + end: () => act(async () => end()), + restore: () => { + HTMLElement.prototype.getAnimations = before; + }, + }; +} + +test("closed, it stays drawn through its exit and tells its parent once, after", async () => { + let closed = 0; + const view = render( closed++} />); + const close = await view.findByRole("button", { name: "Close" }); + const exit = exiting(); + try { + fireEvent.click(close); + // Not on the press: the parent would take it away before its exit could play. + await wait(20); + expect(closed).toBe(0); + expect(view.getByTestId("sheet").hasAttribute("data-ending-style")).toBe( + true, + ); + // A second close while it goes, by Escape say, does not tell the parent twice. + fireEvent.keyDown(view.getByTestId("sheet"), { key: "Escape" }); + await exit.end(); + await wait(20); + expect(closed).toBe(1); + expect(view.queryByRole("dialog")).toBeNull(); + await wait(SHEET_EXIT_GRACE_MS); + expect(closed).toBe(1); + } finally { + exit.restore(); + } +}); + +test("an exit that never says it ended still closes, once the longest exit has had its time", async () => { + let closed = 0; + const view = render( closed++} />); + const close = await view.findByRole("button", { name: "Close" }); + const exit = exiting(); + try { + fireEvent.click(close); + await wait(SHEET_EXIT_GRACE_MS - 100); + expect(closed).toBe(0); + await wait(150); + expect(closed).toBe(1); + } finally { + exit.restore(); + } +}); diff --git a/openbot/app/tests/swipe-to-answer.test.tsx b/openbot/app/tests/swipe-to-answer.test.tsx index 858eabb6..b2621c63 100644 --- a/openbot/app/tests/swipe-to-answer.test.tsx +++ b/openbot/app/tests/swipe-to-answer.test.tsx @@ -106,6 +106,10 @@ describe("the card, while it is slid", () => { await card.hold(THRESHOLD * 0.25); const quarter = blurOf(card.content); const quarterTint = opacityOf(card.tint("start")); + // The content fades on the blur's own ramp: already going a quarter of the way. + const quarterContent = opacityOf(card.content); + expect(quarterContent).toBeLessThan(1); + expect(quarterContent).toBeGreaterThan(0.4); // Already unreadable a quarter of the way: the card reads as on its way the moment it moves. expect(quarter).toBeGreaterThan(4); expect(quarterTint).toBeGreaterThan(0); @@ -122,8 +126,12 @@ describe("the card, while it is slid", () => { // The blur is the content's own, drawn on an opaque slab of the card's colour. expect(card.content.style.filter).toMatch(/^blur\(/); expect(opacityOf(card.slab)).toBe(1); - // Clipped to the card's corners, so the slab keeps its edge. - expect(card.content.style.clipPath).toBe("inset(0 round 22px)"); + // Clipped to the card's own corners, the card token, so the slab keeps its edge. + expect(card.content.style.clipPath).toBe( + "inset(0 round var(--duet-radius-card))", + ); + // And faded to a ghost under the tint, so nothing dark in it is blurred out to that edge. + expect(opacityOf(card.content)).toBeCloseTo(0.4, 2); // And it still tilts toward where it is going. expect(card.layer.style.transform).toContain("rotate(4deg)"); @@ -148,6 +156,7 @@ describe("the card, while it is slid", () => { const atRest = () => { // `none`, not a zero blur: any filter would cut the card's glass off from what is behind it. expect(card.content.style.filter).toBe("none"); + expect(opacityOf(card.content)).toBe(1); expect(card.content.style.clipPath).toBe("none"); expect(opacityOf(card.slab)).toBe(0); expect(opacityOf(card.tint("start"))).toBe(0); @@ -225,6 +234,7 @@ describe("the card, while it is slid", () => { await runFrames(6); const midway = travelOf(card.layer); expect(midway).toBeGreaterThan(THRESHOLD + 20); + expect(opacityOf(card.layer)).toBe(1); expect(midway).toBeLessThan(OFF_SCREEN); expect(blurOf(card.content)).toBe(14); expect(card.mark("start").getAttribute("data-state")).toBe("filled"); @@ -239,6 +249,9 @@ describe("the card, while it is slid", () => { await runFrames(4); expect(travelOf(card.layer)).toBe(OFF_SCREEN); expect(card.answers).toEqual(["start"]); + // Faded out over the second half of the way, so it dissolves at the edge of what shows it + // rather than being cut off by it. + expect(opacityOf(card.layer)).toBe(0); }); test("let go past the threshold in a tab that gets no frames, it still answers, once", async () => { diff --git a/openbot/app/tests/tasks-screen.test.tsx b/openbot/app/tests/tasks-screen.test.tsx index 32eb4936..5ac72a8b 100644 --- a/openbot/app/tests/tasks-screen.test.tsx +++ b/openbot/app/tests/tasks-screen.test.tsx @@ -24,6 +24,7 @@ import { } from "@/components/shell/tasks-screen"; import { agentKeys } from "@/lib/agents/queries"; import { queryClient } from "@/query-client"; +import { runMotionFrames } from "./run-motion-frames"; import { settleReactWork } from "./settle-react-work"; /** @@ -1150,6 +1151,49 @@ test("an Up next card slides off: right starts it, left hides it, a short drag d ); }); +test("a swiped card's place closes as soon as it has flown, and it comes back with the reason if starting fails", async () => { + quiet(); + suggestions = [ + suggestion({ id: "s-1" }), + suggestion({ + id: "s-2", + title: "Pay the booth balance", + createdAt: ago(90), + }), + ]; + serving(); + // The start is held open, so what the list does before the answer is in can be seen. + let refuse = () => {}; + const served = global.fetch; + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "/api/proactive/suggestions/s-1/start") + return new Promise((resolve) => { + refuse = () => + resolve( + Response.json({ error: "Couldn't start that" }, { status: 500 }), + ); + }); + return served(input, init); + }) as unknown as typeof fetch; + const view = await upNext(); + swipe(await view.findByText("Reply to Rosa about the glaze"), 200); + await flown(); + await runMotionFrames(40); + // Flown off and gone from the list while the answer is still on its way: the card under it has + // its place, rather than a gap the size of a card waiting on the server. + await waitFor(() => + expect(view.queryByText("Reply to Rosa about the glaze")).toBeNull(), + ); + expect(view.getByText("Pay the booth balance")).toBeTruthy(); + + refuse(); + // Refused, it is back in its place, with why on it. + expect(await view.findByText("Reply to Rosa about the glaze")).toBeTruthy(); + expect((await view.findByRole("alert")).textContent).toBe( + "Couldn't start that", + ); +}); + test("empty, Up next asks what it can take off your plate, and Show me checks in now", async () => { quiet(); serving();