summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
committerAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
commit83442d00d9d9c7f0ca200d6a2e8b42195708493f (patch)
treeebeeef6c49d4cc3f5974e59b1156de3e5e4c7c6d /src/features/tabs
parenta4aa86e66dad93f40bce640545d8903f7e6bcb64 (diff)
parentc8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff)
downloaddispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.tar.gz
dispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.zip
Merge local dev into feature/close-tab-cascade-subagents
Diffstat (limited to 'src/features/tabs')
-rw-r--r--src/features/tabs/index.ts4
-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/tab-groups.test.ts31
-rw-r--r--src/features/tabs/tab-groups.ts16
-rw-r--r--src/features/tabs/ui.test.ts148
-rw-r--r--src/features/tabs/ui/TabCard.svelte2
-rw-r--r--src/features/tabs/ui/TabGroupBox.svelte40
-rw-r--r--src/features/tabs/ui/TabList.svelte24
-rw-r--r--src/features/tabs/ui/tab-flip.ts374
10 files changed, 720 insertions, 16 deletions
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index 0094528..145a3d9 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -1,12 +1,14 @@
-export type { TabGroupForest, TabGroupNode } from "./tab-groups";
+export type { GroupTone, TabGroupForest, TabGroupNode } from "./tab-groups";
export {
buildTabGroups,
cascadeCloseIds,
directGroupChildren,
+ GROUP_TONES,
groupBleedPx,
groupDescendants,
groupGutterPx,
groupParentCandidates,
+ groupTone,
lastRowIsGroup,
visibleGroupParents,
} from "./tab-groups";
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/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts
index a89a469..67150ff 100644
--- a/src/features/tabs/tab-groups.test.ts
+++ b/src/features/tabs/tab-groups.test.ts
@@ -7,6 +7,7 @@ import {
groupDescendants,
groupGutterPx,
groupParentCandidates,
+ groupTone,
lastRowIsGroup,
visibleGroupParents,
} from "./tab-groups";
@@ -132,6 +133,36 @@ describe("cascadeCloseIds (the ids a cascading tab close removes)", () => {
});
});
+describe("groupTone", () => {
+ it("rotates three colours by level, starting at the outermost box", () => {
+ expect([1, 2, 3, 4, 5, 6, 7].map(groupTone)).toEqual([
+ "secondary",
+ "success",
+ "accent",
+ "secondary",
+ "success",
+ "accent",
+ "secondary",
+ ]);
+ });
+
+ it("never gives a box the colour of its parent or grandparent", () => {
+ for (let level = 3; level <= 20; level++) {
+ expect(groupTone(level)).not.toBe(groupTone(level - 1));
+ expect(groupTone(level)).not.toBe(groupTone(level - 2));
+ }
+ });
+
+ it("never uses primary (reserved for the active tab)", () => {
+ for (let level = 1; level <= 6; level++) expect(groupTone(level)).not.toBe("primary");
+ });
+
+ it("stays in range for out-of-range levels", () => {
+ expect(groupTone(0)).toBe("accent");
+ expect(groupTone(-1)).toBe("success");
+ });
+});
+
describe("groupBleedPx / groupGutterPx", () => {
it("is zero without groups", () => {
expect(groupGutterPx(0)).toBe(0);
diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts
index a1aa464..ff3ddd4 100644
--- a/src/features/tabs/tab-groups.ts
+++ b/src/features/tabs/tab-groups.ts
@@ -169,6 +169,22 @@ export function lastRowIsGroup(children: readonly TabGroupNode[]): boolean {
}
/**
+ * The DaisyUI theme colour a group box at nesting `level` (1 = outermost) is
+ * tinted with. Three colours ROTATE by level, so a box always differs from
+ * the box around it AND from the one around that (with two colours a box
+ * would match its grandparent). `primary` is deliberately excluded — it marks
+ * the active tab, which must stay legible inside any box.
+ */
+export const GROUP_TONES = ["secondary", "success", "accent"] as const;
+export type GroupTone = (typeof GROUP_TONES)[number];
+
+export function groupTone(level: number): GroupTone {
+ const n = GROUP_TONES.length;
+ const i = (((Math.trunc(level) - 1) % n) + n) % n; // non-negative even for level < 1
+ return GROUP_TONES[i] ?? GROUP_TONES[0];
+}
+
+/**
* Horizontal bleed (px) of a group box at nesting `level` (1 = outermost)
* when the deepest box is `maxDepth`. A box bleeds OUTWARD by this much (a
* negative margin + equal padding), so the cards inside every box sit on
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 0243ea4..88b89bf 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
+import { gsap } from "gsap";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Tab } from "./tabs";
import TabList from "./ui/TabList.svelte";
@@ -186,7 +187,7 @@ describe("TabList", () => {
}
});
- it("fixes the tab grid region at 40vh so a long set scrolls internally", () => {
+ it("fixes the tab grid region at 70vh so a long set scrolls internally", () => {
render(TabList, {
props: {
tabs: sampleTabs,
@@ -199,7 +200,7 @@ describe("TabList", () => {
const tabs = screen.getAllByRole("tab");
const region = tabs[0]?.parentElement?.parentElement;
- expect(region).toHaveClass("h-[40vh]");
+ expect(region).toHaveClass("h-[70vh]");
expect(region).toHaveClass("overflow-y-auto");
});
@@ -383,9 +384,16 @@ describe("TabList", () => {
s?.paddingLeft,
]);
}
- // Every box paints a 15% lavender tint, so nested boxes compound per level.
- expect(outer).toHaveClass("bg-secondary/15");
- expect(inner).toHaveClass("bg-secondary/15");
+ // Each level is tinted with its own rotating theme colour.
+ expect(outer?.dataset.tone).toBe("secondary");
+ expect(inner?.dataset.tone).toBe("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}`);
});
@@ -416,6 +424,134 @@ describe("TabList", () => {
expect(document.querySelector("[data-tab-group]")).toBeNull();
});
+ describe("FLIP animation (motion allowed)", () => {
+ /** Jump every running GSAP animation to `progress` (0–1), delays included. */
+ function seekAnimations(progress: number): void {
+ for (const t of gsap.globalTimeline.getChildren(true, true, false)) t.progress(progress);
+ }
+
+ // 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 } });
+ // jsdom lays nothing out (every box is 0×0) — give elements a real size.
+ const rect = vi
+ .spyOn(Element.prototype, "getBoundingClientRect")
+ .mockReturnValue(new DOMRect(0, 0, 120, 72));
+ // Close c1: its card AND p's group box leave.
+ await rerender({ ...props, tabs: two.filter((t) => t.conversationId !== "c1") });
+ rect.mockRestore();
+
+ 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");
+ // A clipping frame around a copy of the tint only — no card copies.
+ expect(boxGhost.childElementCount).toBe(1);
+ expect(boxGhost.firstElementChild?.childElementCount).toBe(0);
+ // Ghosts SHRINK (a clipped height), they don't fade or scale: halfway
+ // through, each is shorter than it started; at the end they're gone.
+ const full = ghosts.map((g) => Number.parseFloat(g.style.height));
+ seekAnimations(0.5);
+ ghosts.forEach((g, i) => {
+ expect(g.style.overflow).toBe("hidden");
+ expect(g.style.opacity).toBe("");
+ expect(Number.parseFloat(g.style.height)).toBeLessThan(full[i] ?? 0);
+ });
+ 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);
+ seekAnimations(1);
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ });
+
+ it("measures each element at most twice per change (no layout thrash)", async () => {
+ // Regression: GSAP's Flip plugin cost ~430ms of main-thread work per
+ // close with ~40 nested tabs. The hand-rolled FLIP reads every element's
+ // box once before the change and once after — never per tween.
+ const { rerender } = render(TabList, { props: { ...props, tabs: grouped } });
+ const elements = document.querySelectorAll("[data-flip-id]").length;
+ const spy = vi.spyOn(Element.prototype, "getBoundingClientRect");
+ try {
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") });
+ // 2 per element + 1 host read per ghost (one ghost here: c2's card).
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2 * elements + 1);
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
+ 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);
+ // New cards start fully clipped (0 visible height) and grow in from
+ // the top edge; once done no motion style is left behind.
+ const ids = ["c1", "c2", "g"];
+ for (const id of ids) expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)");
+ seekAnimations(1);
+ for (const id of ids) {
+ expect(card(id).style.clipPath).toBe("");
+ expect(card(id).style.transform).toBe("");
+ }
+ });
+ });
+
+ 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 a941814..5b644e0 100644
--- a/src/features/tabs/ui/TabGroupBox.svelte
+++ b/src/features/tabs/ui/TabGroupBox.svelte
@@ -1,7 +1,8 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { TabGroupNode } from "../tab-groups";
- import { groupBleedPx, lastRowIsGroup } 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";
@@ -13,9 +14,13 @@
* inside stay on the same two columns as top-level cards: every card keeps
* identical dimensions at every depth. The padding is EQUAL on all four
* sides (= the bleed) so the box looks even — except below a nested box on
- * the last row, where it is trimmed to match that box's side gaps. Every
- * box paints a 15% secondary (lavender) tint, which compounds: each
- * nesting level reads a little stronger than the one around it.
+ * the last row, where it is trimmed to match that box's side gaps. Each
+ * 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. 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,
@@ -32,6 +37,7 @@
} = $props();
const bleed = $derived(groupBleedPx(level, maxDepth));
+ const tone = $derived(groupTone(level));
// Even spacing: when the last row is a nested box, the gap below it would
// be this box's full padding while its side gaps are only the DIFFERENCE
// in bleed (the nested box bleeds outward too) — trim the bottom to match.
@@ -41,15 +47,23 @@
</script>
<div
- class="col-span-2 rounded-lg bg-secondary/15"
+ class="relative col-span-2"
style:margin-inline="-{bleed}px"
style:padding="{bleed}px {bleed}px {padBottom}px"
role="group"
aria-label={`Subagents of ${node.tab.title}`}
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}
@@ -60,3 +74,17 @@
{/each}
</div>
</div>
+
+<style>
+ /* 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-backdrop {
+ background-color: color-mix(
+ in oklab,
+ var(--group-tone) 16%,
+ var(--inset-well-bg, var(--color-base-200))
+ );
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--group-tone) 55%, transparent);
+ }
+</style>
diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte
index 4b96e08..0493b5c 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,
cascadeCloseIds,
@@ -13,6 +15,7 @@
import TabCard from "./TabCard.svelte";
import TabGroupBox from "./TabGroupBox.svelte";
import TabMenu from "./TabMenu.svelte";
+ import { createTabFlip } from "./tab-flip";
let {
tabs,
@@ -53,6 +56,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(
@@ -112,17 +129,18 @@
{/snippet}
<div class="flex flex-col gap-2">
- <!-- 2-column grid of same-size tab cards, fixed at 40% of the viewport height
+ <!-- 2-column grid of same-size tab cards, fixed at 70% of the viewport height
so a long tab set scrolls inside this region instead of growing the
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-[70vh] 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..ac9daaf
--- /dev/null
+++ b/src/features/tabs/ui/tab-flip.ts
@@ -0,0 +1,374 @@
+import { gsap } from "gsap";
+import { isGroupFlipId, leavingFlipIds } from "../tab-flip";
+
+/**
+ * FLIP animation for the tab grid — the DOM EFFECT half (the pure id scheme
+ * lives in `../tab-flip.ts`).
+ *
+ * Driven by the owning component in two calls:
+ * - `capture(nextIds)` — BEFORE the DOM updates: measure every
+ * `[data-flip-id]` element (First), and build an inert GHOST for each
+ * element the next render drops (a closed tab / dissolved group box).
+ * - `play()` — AFTER the DOM updates: measure again (Last), then run three
+ * STAGGERED phases, each only when it has something to do —
+ * 1. SHRINK — ghosts collapse to 0 height (everything else holds still);
+ * 2. MOVE — cards slide from their old box to their new one, and each
+ * group box's tint morphs from its old rect to its new one;
+ * 3. GROW — new cards (and new group boxes' tints) grow from 0 height.
+ *
+ * PERFORMANCE: this is a hand-rolled FLIP, not GSAP's Flip plugin. Flip
+ * recorded computed styles per element and re-measured while building its
+ * timeline — ~430ms of main-thread work per close with ~40 nested tabs, which
+ * both delayed the click and swallowed the shrink phase. Here each change
+ * costs exactly two batched layout reads (one in `capture`, one in `play`);
+ * every write happens after its batch of reads. Each phase is ONE GSAP tween
+ * on a plain progress value whose `onUpdate` writes inline styles directly —
+ * GSAP never reads an element's computed style (per-element CSS tweens did,
+ * forcing a style recalc per moved card). Cards only TRANSLATE (plus a
+ * width tween when a card turns into / out of a double-width group parent);
+ * a group box never moves itself — only its absolutely-positioned, empty tint
+ * layer does (inset tween), so no card is ever double-translated.
+ *
+ * Like the sidebar panels (`layout/ui/panel-motion.ts`), shrink/grow change
+ * only a HEIGHT, clipped — contents keep their natural size. Grow is a
+ * `clip-path` reveal so the settled grid never shifts under it.
+ *
+ * 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 —
+ * `capture` measures the in-flight VISUAL boxes, `play` clears in-flight
+ * motion before measuring the new layout. 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 SHRINK = 0.1;
+const MOVE = 0.1;
+const GROW = 0.1;
+/** Close: starts slow, finishes fast (accelerates away). */
+const SHRINK_EASE = "power2.in";
+/** Move: slow → fast → slow. */
+const MOVE_EASE = "power2.inOut";
+/** Open: starts fast, settles slowly (the mirror of close). */
+const GROW_EASE = "power2.out";
+const GHOST_ATTR = "data-tab-ghost";
+/** Fully clipped from the bottom edge up (0 visible height). */
+const CLIP_HIDDEN = "inset(0% 0% 100% 0%)";
+/** Every inline style this module may leave on a live element mid-flight. */
+const MOTION_PROPS = ["transform", "width", "clip-path", "top", "right", "bottom", "left"];
+
+interface Captured {
+ /** Visual box (incl. any in-flight motion) per flip id. Cards: the card; boxes: the tint. */
+ readonly rects: ReadonlyMap<string, DOMRect>;
+ /** Built but not yet started — their shrink starts in `play`. */
+ readonly ghosts: readonly HTMLElement[];
+}
+
+export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFlip {
+ let captured: Captured | null = null;
+ /** Live elements carrying motion styles, so `play` can reset exactly those. */
+ const animating = new Set<HTMLElement>();
+ /** The running MOVE + GROW phase tweens (ghost shrinks run independently). */
+ let phases: gsap.core.Tween[] = [];
+
+ return {
+ capture(nextIds) {
+ captured = null;
+ const scroller = getScroller();
+ if (scroller === undefined || !motionAllowed()) return;
+ const els = flipElements(scroller);
+ if (els.length === 0) return;
+
+ // READS (one layout).
+ const rects = new Map<string, DOMRect>();
+ for (const el of els) rects.set(idOf(el), measureTarget(el).getBoundingClientRect());
+ const leaving = leavingFlipIds([...rects.keys()], nextIds);
+ const byId = new Map(els.map((el) => [idOf(el), el]));
+ const plans = leaving
+ .map((id) => byId.get(id))
+ .filter((el): el is HTMLElement => el !== undefined)
+ .map((el) => planGhost(scroller, el, rects.get(idOf(el))))
+ .filter((p): p is GhostPlan => p !== null);
+
+ // WRITES.
+ captured = { rects, ghosts: plans.map(insertGhost) };
+ },
+
+ play() {
+ const prev = captured;
+ captured = null;
+ const scroller = getScroller();
+ if (prev === null || scroller === undefined) return;
+
+ // Reset in-flight motion on live elements so the read below is the
+ // settled layout (their previous VISUAL boxes are already in `prev`).
+ for (const tween of phases) tween.kill();
+ phases = [];
+ for (const el of animating) clearMotion(el);
+ animating.clear();
+
+ // READS (one layout).
+ const next = flipElements(scroller).map((el) => {
+ const target = measureTarget(el);
+ return { target, id: idOf(el), rect: target.getBoundingClientRect() };
+ });
+
+ // Plan (pure arithmetic — no DOM access).
+ const entering: HTMLElement[] = [];
+ const movers: Mover[] = [];
+ for (const { target, id, rect } of next) {
+ const before = prev.rects.get(id);
+ if (before === undefined) entering.push(target);
+ else if (!sameBox(before, rect))
+ movers.push(planMove(target, isGroupFlipId(id), before, rect));
+ }
+ const moveAt = prev.ghosts.length > 0 ? SHRINK : 0;
+ const growAt = moveAt + (movers.length > 0 ? MOVE : 0);
+
+ // WRITES — first frame of every phase NOW (movers hold at their old
+ // boxes, new elements are fully clipped) so nothing flashes in between.
+ for (const m of movers) m.render(0);
+ for (const el of entering) el.style.clipPath = CLIP_HIDDEN;
+
+ // 1. SHRINK — one tween drives every ghost.
+ if (prev.ghosts.length > 0) {
+ const ghosts = prev.ghosts.map((g) => ({ g, h: Number.parseFloat(g.style.height) }));
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: SHRINK,
+ ease: SHRINK_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ for (const { g, h } of ghosts) g.style.height = `${h * (1 - p)}px`;
+ },
+ onComplete: () => {
+ for (const { g } of ghosts) g.remove();
+ },
+ },
+ );
+ }
+
+ // 2. MOVE — one tween drives every card slide + tint morph.
+ if (movers.length > 0) {
+ for (const m of movers) animating.add(m.el);
+ phases.push(
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: MOVE,
+ delay: moveAt,
+ ease: MOVE_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ for (const m of movers) m.render(p);
+ },
+ onComplete: () => {
+ for (const m of movers) {
+ clearMotion(m.el);
+ animating.delete(m.el);
+ }
+ },
+ },
+ ),
+ );
+ }
+
+ // 3. GROW — one tween reveals every new card / tint from its top edge.
+ if (entering.length > 0) {
+ for (const el of entering) animating.add(el);
+ phases.push(
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: GROW,
+ delay: growAt,
+ ease: GROW_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ const clip = `inset(0% 0% ${(1 - p) * 100}% 0%)`;
+ for (const el of entering) el.style.clipPath = clip;
+ },
+ onComplete: () => {
+ for (const el of entering) {
+ clearMotion(el);
+ animating.delete(el);
+ }
+ },
+ },
+ ),
+ );
+ }
+ },
+ };
+}
+
+interface Mover {
+ readonly el: HTMLElement;
+ /** Write the element's styles at move progress `p` (0 = old box, 1 = home). */
+ render(p: number): void;
+}
+
+/**
+ * A card slides (and, when it turned into / out of a double-width group
+ * parent, resizes) from its old box; a group tint morphs its insets from its
+ * old rect. Both end at their natural layout (`p = 1` ⇒ zero offsets).
+ */
+function planMove(el: HTMLElement, isTint: boolean, from: DOMRect, to: DOMRect): Mover {
+ if (isTint) {
+ const top = from.top - to.top;
+ const left = from.left - to.left;
+ const right = to.right - from.right;
+ const bottom = to.bottom - from.bottom;
+ return {
+ el,
+ render(p) {
+ const k = 1 - p;
+ el.style.top = `${top * k}px`;
+ el.style.left = `${left * k}px`;
+ el.style.right = `${right * k}px`;
+ el.style.bottom = `${bottom * k}px`;
+ },
+ };
+ }
+ const dx = from.left - to.left;
+ const dy = from.top - to.top;
+ const resized = Math.abs(from.width - to.width) >= 0.5;
+ return {
+ el,
+ render(p) {
+ const k = 1 - p;
+ el.style.transform = `translate(${dx * k}px, ${dy * k}px)`;
+ if (resized) el.style.width = `${to.width + (from.width - to.width) * k}px`;
+ },
+ };
+}
+
+function clearMotion(el: HTMLElement): void {
+ for (const prop of MOTION_PROPS) el.style.removeProperty(prop);
+}
+
+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]")];
+}
+
+function idOf(el: HTMLElement): string {
+ return el.dataset.flipId ?? "";
+}
+
+/** What VISUALLY represents a flip element: a card itself, a group box's tint. */
+function measureTarget(el: HTMLElement): HTMLElement {
+ if (!isGroupFlipId(idOf(el))) return el;
+ return el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") ?? el;
+}
+
+/** Whether two rects are the same box (sub-pixel tolerance). */
+function sameBox(a: DOMRect, b: DOMRect): boolean {
+ const close = (x: number, y: number) => Math.abs(x - y) < 0.5;
+ return (
+ close(a.left, b.left) &&
+ close(a.top, b.top) &&
+ close(a.width, b.width) &&
+ close(a.height, b.height)
+ );
+}
+
+interface GhostPlan {
+ readonly source: HTMLElement;
+ readonly host: HTMLElement;
+ readonly isGroup: boolean;
+ readonly left: number;
+ readonly top: number;
+ readonly width: number;
+ readonly height: number;
+}
+
+/**
+ * READ half of a ghost: where an inert copy of a leaving element must sit.
+ *
+ * Paint order matters (cards are `relative`, so everything here stacks in DOM
+ * order): a leaving CARD's ghost goes on the scroller, ON TOP of the grid. A
+ * leaving GROUP BOX copies only its tint and goes FIRST in the box's
+ * enclosing grid: above the enclosing box's own tint, but BELOW every card.
+ */
+function planGhost(
+ scroller: HTMLElement,
+ el: HTMLElement,
+ rect: DOMRect | undefined,
+): GhostPlan | null {
+ const isGroup = isGroupFlipId(idOf(el));
+ const source = measureTarget(el);
+ const host = isGroup ? el.parentElement : scroller;
+ if (host === null || rect === undefined || (isGroup && source === el)) return null;
+ const h = host.getBoundingClientRect();
+ return {
+ source,
+ host,
+ isGroup,
+ left: rect.left - h.left - host.clientLeft + host.scrollLeft,
+ top: rect.top - h.top - host.clientTop + host.scrollTop,
+ width: rect.width,
+ height: rect.height,
+ };
+}
+
+/**
+ * WRITE half of a ghost: a clipping frame (whose height later shrinks to 0)
+ * around a full-size copy — only the frame animates, the copy never resizes.
+ */
+function insertGhost(plan: GhostPlan): HTMLElement {
+ const copy = plan.source.cloneNode(!plan.isGroup) as HTMLElement;
+ // A ghost is never a tab/group to queries, tests or assistive tech.
+ for (const node of [copy, ...copy.querySelectorAll<HTMLElement>("*")]) {
+ for (const attr of [
+ "data-flip-id",
+ "data-conversation-id",
+ "data-group-backdrop",
+ "role",
+ "id",
+ ]) {
+ node.removeAttribute(attr);
+ }
+ }
+ Object.assign(copy.style, {
+ position: "absolute",
+ inset: "auto",
+ left: "0",
+ top: "0",
+ width: `${plan.width}px`,
+ height: `${plan.height}px`,
+ margin: "0",
+ transform: "none",
+ clipPath: "none",
+ });
+ const ghost = document.createElement("div");
+ ghost.setAttribute(GHOST_ATTR, "");
+ ghost.setAttribute("aria-hidden", "true");
+ ghost.inert = true;
+ Object.assign(ghost.style, {
+ position: "absolute",
+ left: `${plan.left}px`,
+ top: `${plan.top}px`,
+ width: `${plan.width}px`,
+ height: `${plan.height}px`,
+ overflow: "hidden",
+ pointerEvents: "none",
+ zIndex: plan.isGroup ? "auto" : "2",
+ });
+ ghost.appendChild(copy);
+ if (plan.isGroup) plan.host.prepend(ghost);
+ else plan.host.appendChild(ghost);
+ return ghost;
+}