From b363753dca1e631615156ad572353ca2a2391e34 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 21 Oct 2026 20:48:53 +0900 Subject: feat(tabs): GSAP Flip animation when subagents are summoned or tabs close --- src/features/tabs/tab-flip.test.ts | 52 +++++++++++ src/features/tabs/tab-flip.ts | 45 +++++++++ src/features/tabs/ui.test.ts | 91 +++++++++++++++++- src/features/tabs/ui/TabCard.svelte | 2 + src/features/tabs/ui/TabGroupBox.svelte | 19 +++- src/features/tabs/ui/TabList.svelte | 22 ++++- src/features/tabs/ui/tab-flip.ts | 157 ++++++++++++++++++++++++++++++++ 7 files changed, 378 insertions(+), 10 deletions(-) create mode 100644 src/features/tabs/tab-flip.test.ts create mode 100644 src/features/tabs/tab-flip.ts create mode 100644 src/features/tabs/ui/tab-flip.ts (limited to 'src') 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:`. 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 { + const out = new Set(); + 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[] { + 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(":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("[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 @@
-
+ +
{@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 @@