diff options
Diffstat (limited to 'src/features/tabs')
| -rw-r--r-- | src/features/tabs/index.ts | 4 | ||||
| -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/tab-groups.test.ts | 31 | ||||
| -rw-r--r-- | src/features/tabs/tab-groups.ts | 16 | ||||
| -rw-r--r-- | src/features/tabs/ui.test.ts | 148 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabCard.svelte | 2 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabGroupBox.svelte | 40 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabList.svelte | 24 | ||||
| -rw-r--r-- | src/features/tabs/ui/tab-flip.ts | 374 |
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; +} |
