diff options
| author | Adam Malczewski <[email protected]> | 2026-10-21 20:48:53 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-21 20:48:53 +0900 |
| commit | b363753dca1e631615156ad572353ca2a2391e34 (patch) | |
| tree | 9d7a2c7c616bd3db485f0894afbc003d530e466b /src/features | |
| parent | bac1e1c39398ddcb0866db510c11bed75ac3a9a8 (diff) | |
| download | dispatch-web-b363753dca1e631615156ad572353ca2a2391e34.tar.gz dispatch-web-b363753dca1e631615156ad572353ca2a2391e34.zip | |
feat(tabs): GSAP Flip animation when subagents are summoned or tabs close
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/tabs/tab-flip.test.ts | 52 | ||||
| -rw-r--r-- | src/features/tabs/tab-flip.ts | 45 | ||||
| -rw-r--r-- | src/features/tabs/ui.test.ts | 91 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabCard.svelte | 2 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabGroupBox.svelte | 19 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabList.svelte | 22 | ||||
| -rw-r--r-- | src/features/tabs/ui/tab-flip.ts | 157 |
7 files changed, 378 insertions, 10 deletions
diff --git a/src/features/tabs/tab-flip.test.ts b/src/features/tabs/tab-flip.test.ts new file mode 100644 index 0000000..43e966f --- /dev/null +++ b/src/features/tabs/tab-flip.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { cardFlipId, forestFlipIds, groupFlipId, isGroupFlipId, leavingFlipIds } from "./tab-flip"; +import { buildTabGroups } from "./tab-groups"; +import type { Tab } from "./tabs"; + +function tab(id: string, parent?: string): Tab { + return { + conversationId: id, + model: "m", + title: id, + workspaceId: "w", + ...(parent !== undefined ? { tabGroupParentId: parent } : {}), + }; +} + +describe("flip ids", () => { + it("distinguishes a card from the group box of the same conversation", () => { + expect(cardFlipId("p")).not.toBe(groupFlipId("p")); + expect(isGroupFlipId(groupFlipId("p"))).toBe(true); + expect(isGroupFlipId(cardFlipId("p"))).toBe(false); + }); +}); + +describe("forestFlipIds", () => { + it("lists every card plus a box for each tab that has visible subagents", () => { + const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("solo")]); + expect([...forestFlipIds(forest)].sort()).toEqual( + ["c", "g", "group:c", "group:p", "p", "solo"].sort(), + ); + }); + + it("has no boxes when nothing is grouped", () => { + expect([...forestFlipIds(buildTabGroups([tab("a"), tab("b")]))]).toEqual(["a", "b"]); + }); +}); + +describe("leavingFlipIds", () => { + it("returns the rendered ids the next render drops, in rendered order", () => { + const before = buildTabGroups([tab("p"), tab("c", "p"), tab("solo")]); + // Closing the only subagent drops its card AND the parent's group box. + const after = buildTabGroups([tab("p"), tab("solo")]); + expect(leavingFlipIds([...forestFlipIds(before)], forestFlipIds(after))).toEqual([ + "group:p", + "c", + ]); + }); + + it("is empty when a tab is only added", () => { + const next = forestFlipIds(buildTabGroups([tab("a"), tab("b", "a")])); + expect(leavingFlipIds(["a"], next)).toEqual([]); + }); +}); diff --git a/src/features/tabs/tab-flip.ts b/src/features/tabs/tab-flip.ts new file mode 100644 index 0000000..3bb4c03 --- /dev/null +++ b/src/features/tabs/tab-flip.ts @@ -0,0 +1,45 @@ +import type { TabGroupForest, TabGroupNode } from "./tab-groups"; + +/** + * Flip ids — the PURE identity scheme behind the tab grid's FLIP animation. + * + * Every animated element carries a `data-flip-id`: a tab card uses its + * conversation id, a group box uses `group:<parent conversation id>`. The ids + * are stable across re-renders even when the DOM node is replaced (a card + * becoming a double-width group parent is a NEW node), which is what lets + * GSAP Flip morph the old element into the new one. + */ + +const GROUP_PREFIX = "group:"; + +/** Flip id of a tab card. */ +export function cardFlipId(conversationId: string): string { + return conversationId; +} + +/** Flip id of the group box wrapping `parentConversationId` + its subagents. */ +export function groupFlipId(parentConversationId: string): string { + return `${GROUP_PREFIX}${parentConversationId}`; +} + +/** Whether a flip id names a group box (vs a tab card). */ +export function isGroupFlipId(flipId: string): boolean { + return flipId.startsWith(GROUP_PREFIX); +} + +/** Every flip id the grid renders for `forest` (cards + group boxes). */ +export function forestFlipIds(forest: TabGroupForest): Set<string> { + const out = new Set<string>(); + const walk = (node: TabGroupNode): void => { + out.add(cardFlipId(node.tab.conversationId)); + if (node.children.length > 0) out.add(groupFlipId(node.tab.conversationId)); + for (const child of node.children) walk(child); + }; + for (const root of forest.roots) walk(root); + return out; +} + +/** The currently-rendered flip ids that the NEXT render drops (order kept). */ +export function leavingFlipIds(current: readonly string[], next: ReadonlySet<string>): string[] { + return current.filter((id) => !next.has(id)); +} diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 8785ea9..743b98d 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Tab } from "./tabs"; import TabList from "./ui/TabList.svelte"; @@ -386,8 +386,13 @@ describe("TabList", () => { // Each level is tinted with its own rotating theme colour. expect(outer?.dataset.tone).toBe("secondary"); expect(inner?.dataset.tone).toBe("success"); - expect(outer?.style.getPropertyValue("--group-tone")).toBe("var(--color-secondary)"); - expect(inner?.style.getPropertyValue("--group-tone")).toBe("var(--color-success)"); + // The tint lives on each box's own backdrop layer (FLIP fades it in). + const tint = (box: HTMLElement | null) => + box + ?.querySelector<HTMLElement>(":scope > [data-group-backdrop]") + ?.style.getPropertyValue("--group-tone"); + expect(tint(outer)).toBe("var(--color-secondary)"); + expect(tint(inner)).toBe("var(--color-success)"); expect(outer?.style.getPropertyValue("margin-inline")).toBe(`-${outer?.style.paddingLeft}`); }); @@ -418,6 +423,86 @@ describe("TabList", () => { expect(document.querySelector("[data-tab-group]")).toBeNull(); }); + describe("FLIP animation (motion allowed)", () => { + // jsdom has no matchMedia, which the grid reads as "no motion" — stub + // the outermost edge so the GSAP Flip path actually runs. + beforeEach(() => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: false }) as unknown as MediaQueryList), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const props = { + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup: vi.fn(), + }; + + it("leaves an inert ghost of a closed tab (and its dissolved box) that is never a tab", async () => { + const two = grouped.filter((t) => ["p", "c1", "solo"].includes(t.conversationId)); + const { rerender } = render(TabList, { props: { ...props, tabs: two } }); + // Close c1: its card AND p's group box leave. + await rerender({ ...props, tabs: two.filter((t) => t.conversationId !== "c1") }); + + const ghosts = [...document.querySelectorAll<HTMLElement>("[data-tab-ghost]")]; + expect(ghosts).toHaveLength(2); + for (const g of ghosts) { + expect(g).toHaveAttribute("aria-hidden", "true"); + expect(g.querySelector("[data-conversation-id],[data-flip-id],[role]")).toBeNull(); + } + // Paint order: the dissolving box's tint goes FIRST in its enclosing + // grid (under every card); the closed card's ghost goes on top. + const grid = screen.getByRole("tablist"); + const boxGhost = grid.firstElementChild as HTMLElement; + expect(boxGhost).toHaveAttribute("data-tab-ghost"); + expect(boxGhost.childElementCount).toBe(0); // tint only, no card copies + const cardGhost = ghosts.find((g) => g !== boxGhost); + expect(cardGhost?.parentElement).toBe(grid.parentElement); + expect(cardGhost).toHaveTextContent("Child 1"); + // Ghosts are invisible to queries: only the real remaining tabs count. + expect(screen.getAllByRole("tab").map((t) => t.dataset.conversationId)).toEqual([ + "p", + "solo", + ]); + expect(screen.getAllByRole("button", { name: "Close tab" })).toHaveLength(2); + }); + + it("does not ghost anything when a subagent is summoned", async () => { + const base = grouped.filter((t) => ["p", "solo"].includes(t.conversationId)); + const { rerender } = render(TabList, { props: { ...props, tabs: base } }); + await rerender({ ...props, tabs: grouped }); + expect(document.querySelector("[data-tab-ghost]")).toBeNull(); + expect(screen.getAllByRole("tab")).toHaveLength(grouped.length); + }); + }); + + it("never animates when the user prefers reduced motion", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: true }) as unknown as MediaQueryList), + ); + try { + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") }); + expect(document.querySelector("[data-tab-ghost]")).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("keeps the parent wide while a nested descendant's own parent remains open", async () => { const props = { tabs: grouped, diff --git a/src/features/tabs/ui/TabCard.svelte b/src/features/tabs/ui/TabCard.svelte index a95f8e6..ae891c0 100644 --- a/src/features/tabs/ui/TabCard.svelte +++ b/src/features/tabs/ui/TabCard.svelte @@ -1,4 +1,5 @@ <script lang="ts"> + import { cardFlipId } from "../tab-flip"; import type { Tab } from "../tabs"; /** @@ -115,6 +116,7 @@ aria-selected={active} title={tab.title} data-conversation-id={tab.conversationId} + data-flip-id={cardFlipId(tab.conversationId)} data-wide={wide ? "true" : undefined} onclick={() => { if (suppressClick) { diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte index f9a3ae2..5b644e0 100644 --- a/src/features/tabs/ui/TabGroupBox.svelte +++ b/src/features/tabs/ui/TabGroupBox.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import type { Snippet } from "svelte"; import type { TabGroupNode } from "../tab-groups"; + import { groupFlipId } from "../tab-flip"; import { groupBleedPx, groupTone, lastRowIsGroup } from "../tab-groups"; import type { Tab } from "../tabs"; import TabGroupBox from "./TabGroupBox.svelte"; @@ -17,7 +18,9 @@ * box is tinted with one of three theme colours ROTATING by level * (`groupTone`), painted OPAQUE (mixed into the sidebar well colour) so a * nested box never blends with the box around it, plus a border in the same - * colour so every box edge reads at a glance. + * colour so every box edge reads at a glance. The tint lives on its own + * BACKDROP layer so the FLIP animation (`tab-flip.ts`) can fade a new + * group's tint in while its cards fly into place. */ let { node, @@ -44,8 +47,7 @@ </script> <div - class="tab-group-box col-span-2 rounded-lg" - style:--group-tone="var(--color-{tone})" + class="relative col-span-2" style:margin-inline="-{bleed}px" style:padding="{bleed}px {bleed}px {padBottom}px" role="group" @@ -53,8 +55,15 @@ data-tab-group={node.tab.conversationId} data-level={level} data-tone={tone} + data-flip-id={groupFlipId(node.tab.conversationId)} > - <div class="grid grid-flow-row-dense grid-cols-2 gap-1.5"> + <div + class="tab-group-backdrop pointer-events-none absolute inset-0 rounded-lg" + style:--group-tone="var(--color-{tone})" + data-group-backdrop + aria-hidden="true" + ></div> + <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5"> {@render card(node.tab, true, node.children.length)} {#each node.children as child (child.tab.conversationId)} {#if child.children.length > 0} @@ -70,7 +79,7 @@ /* Opaque tint: the tone mixed into the sidebar's `.inset-well` colour (the surface the tab grid sits on), so nested boxes replace — never compound with — the colour around them. */ - .tab-group-box { + .tab-group-backdrop { background-color: color-mix( in oklab, var(--group-tone) 16%, diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte index 06d5aa6..5bfabf6 100644 --- a/src/features/tabs/ui/TabList.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -1,4 +1,6 @@ <script lang="ts"> + import { untrack } from "svelte"; + import { forestFlipIds } from "../tab-flip"; import { buildTabGroups, directGroupChildren, @@ -11,6 +13,7 @@ import TabCard from "./TabCard.svelte"; import TabGroupBox from "./TabGroupBox.svelte"; import TabMenu from "./TabMenu.svelte"; + import { createTabFlip } from "./tab-flip"; let { tabs, @@ -51,6 +54,20 @@ const forest = $derived(buildTabGroups(tabs)); const gutter = $derived(groupGutterPx(forest.maxDepth)); + // FLIP: a summoned subagent / closed tab / regroup animates (GSAP Flip). + // Capture positions (+ ghost leaving elements) BEFORE the DOM updates, play + // AFTER. Keyed on `forest` only; the DOM work itself is untracked. + let scrollerEl = $state<HTMLDivElement | undefined>(); + const flip = createTabFlip(() => scrollerEl); + $effect.pre(() => { + const next = forestFlipIds(forest); + untrack(() => flip.capture(next)); + }); + $effect(() => { + void forest; + untrack(() => flip.play()); + }); + // Tab menu (right-click / touch-and-hold). Local UI state owned here. let menu = $state<{ conversationId: string; x: number; y: number } | null>(null); const menuTab = $derived( @@ -95,12 +112,13 @@ sidebar. Subagent groups span both columns; `dense` lets a later card fill the half-row a group would otherwise leave empty. --> <div - class="h-[40vh] overflow-y-auto py-0.5" + bind:this={scrollerEl} + class="relative h-[40vh] overflow-y-auto py-0.5" style:padding-inline="{gutter}px" data-tab-grid onscroll={() => (menu = null)} > - <div class="grid grid-flow-row-dense grid-cols-2 gap-1.5" role="tablist"> + <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5" role="tablist"> {#each forest.roots as node (node.tab.conversationId)} {#if node.children.length > 0} <TabGroupBox {node} level={1} maxDepth={forest.maxDepth} {card} /> diff --git a/src/features/tabs/ui/tab-flip.ts b/src/features/tabs/ui/tab-flip.ts new file mode 100644 index 0000000..9d0f0f8 --- /dev/null +++ b/src/features/tabs/ui/tab-flip.ts @@ -0,0 +1,157 @@ +import { gsap } from "gsap"; +import { Flip } from "gsap/Flip"; +import { isGroupFlipId, leavingFlipIds } from "../tab-flip"; + +gsap.registerPlugin(Flip); + +/** + * GSAP Flip animation for the tab grid — the DOM EFFECT half of the tab + * FLIP (the pure id scheme lives in `../tab-flip.ts`). + * + * Two-phase, driven by the owning component: + * - `capture(nextIds)` — BEFORE the DOM updates: record every `[data-flip-id]` + * element's position (First), and spawn an inert GHOST for each element the + * next render drops, so a closed tab / dissolved group box fades out in + * place instead of vanishing. + * - `play()` — AFTER the DOM updates: Flip every surviving element from its + * recorded position to its new one (Last → Invert → Play), scale new cards + * in, and fade new group boxes' backdrops in. + * + * Data-driven, not click-driven: it animates ANY change to the tab set (a + * summoned subagent, a close from another device, a regroup). Re-entrant — + * `Flip.getState` captures mid-flight positions. Disabled when the user + * prefers reduced motion (or the environment can't tell, e.g. jsdom). + */ +export interface TabFlip { + capture(nextIds: ReadonlySet<string>): void; + play(): void; +} + +const DURATION = 0.4; +const GHOST_ATTR = "data-tab-ghost"; + +export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFlip { + let state: ReturnType<typeof Flip.getState> | null = null; + + return { + capture(nextIds) { + state = null; + const scroller = getScroller(); + if (scroller === undefined || !motionAllowed()) return; + const els = flipElements(scroller); + if (els.length === 0) return; + state = Flip.getState(els); + const byId = new Map(els.map((el) => [el.dataset.flipId ?? "", el])); + for (const id of leavingFlipIds([...byId.keys()], nextIds)) { + const el = byId.get(id); + if (el !== undefined) spawnGhost(scroller, el, isGroupFlipId(id)); + } + }, + + play() { + const captured = state; + state = null; + const scroller = getScroller(); + if (captured === null || scroller === undefined) return; + Flip.from(captured, { + targets: flipElements(scroller), + duration: DURATION, + ease: "power3.out", + nested: true, + onEnter: (elements) => { + for (const el of elements) { + if (!(el instanceof HTMLElement)) continue; + if (isGroupFlipId(el.dataset.flipId ?? "")) { + // The box's CARDS are flipping into place — fade only its tint. + const backdrop = el.querySelector<HTMLElement>(":scope > [data-group-backdrop]"); + if (backdrop !== null) { + gsap.fromTo(backdrop, { opacity: 0 }, { opacity: 1, duration: DURATION }); + } + } else { + gsap.fromTo( + el, + { opacity: 0, scale: 0.6 }, + { + opacity: 1, + scale: 1, + duration: DURATION, + delay: DURATION * 0.35, // let the grid open the gap first + ease: "back.out(1.7)", + clearProps: "opacity,transform", + }, + ); + } + } + }, + }); + }, + }; +} + +function motionAllowed(): boolean { + return ( + typeof window.matchMedia === "function" && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +function flipElements(scroller: HTMLElement): HTMLElement[] { + return [...scroller.querySelectorAll<HTMLElement>("[data-flip-id]")]; +} + +/** + * An inert visual copy of a leaving element, absolutely positioned over its + * last on-screen rect, that shrinks + fades out and removes itself. + * + * Paint order matters (cards are `relative`, so everything here stacks in DOM + * order): a leaving CARD's ghost is appended to the scroller so it fades out + * ON TOP of the reflow. A leaving GROUP BOX copies only its backdrop (the + * tint — its cards either flip elsewhere or ghost themselves) and is inserted + * as the FIRST child of the box's enclosing grid: above the enclosing box's + * own tint, but BELOW every card, so the dissolving tint never dims the cards + * flying out of it. + */ +function spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): void { + const source = isGroup ? el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") : el; + if (source === null) return; + const host = isGroup ? el.parentElement : scroller; + if (host === null) return; + const r = el.getBoundingClientRect(); + const h = host.getBoundingClientRect(); + const ghost = source.cloneNode(!isGroup) as HTMLElement; + // A ghost is never a tab/group to queries, tests or assistive tech. + for (const node of [ghost, ...ghost.querySelectorAll<HTMLElement>("*")]) { + for (const attr of [ + "data-flip-id", + "data-conversation-id", + "data-group-backdrop", + "role", + "id", + ]) { + node.removeAttribute(attr); + } + } + ghost.setAttribute(GHOST_ATTR, ""); + ghost.setAttribute("aria-hidden", "true"); + ghost.inert = true; + Object.assign(ghost.style, { + position: "absolute", + left: `${r.left - h.left - host.clientLeft + host.scrollLeft}px`, + top: `${r.top - h.top - host.clientTop + host.scrollTop}px`, + width: `${r.width}px`, + height: `${r.height}px`, + margin: "0", + transform: "none", + pointerEvents: "none", + zIndex: isGroup ? "auto" : "2", + }); + if (isGroup) host.prepend(ghost); + else host.appendChild(ghost); + gsap.to(ghost, { + opacity: 0, + scale: isGroup ? 1 : 0.6, + duration: DURATION * 0.75, + ease: "power2.in", + onComplete: () => ghost.remove(), + }); +} |
