summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 20:48:53 +0900
committerAdam Malczewski <[email protected]>2026-10-21 20:48:53 +0900
commitb363753dca1e631615156ad572353ca2a2391e34 (patch)
tree9d7a2c7c616bd3db485f0894afbc003d530e466b /src/features
parentbac1e1c39398ddcb0866db510c11bed75ac3a9a8 (diff)
downloaddispatch-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.ts52
-rw-r--r--src/features/tabs/tab-flip.ts45
-rw-r--r--src/features/tabs/ui.test.ts91
-rw-r--r--src/features/tabs/ui/TabCard.svelte2
-rw-r--r--src/features/tabs/ui/TabGroupBox.svelte19
-rw-r--r--src/features/tabs/ui/TabList.svelte22
-rw-r--r--src/features/tabs/ui/tab-flip.ts157
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(),
+ });
+}