summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 21:53:21 +0900
committerAdam Malczewski <[email protected]>2026-10-21 21:53:21 +0900
commitae0a99aeec89580fa1121def99c954a1650fd4cb (patch)
treea514a29e1e96b6fc66a5c46a446f56ed107f7da6 /src/features
parent511d415b4ec07d435ea73ab8e5421949392d922d (diff)
downloaddispatch-web-ae0a99aeec89580fa1121def99c954a1650fd4cb.tar.gz
dispatch-web-ae0a99aeec89580fa1121def99c954a1650fd4cb.zip
perf(tabs): hand-rolled FLIP — close animation starts instantly (was ~430ms of GSAP Flip work)
Diffstat (limited to 'src/features')
-rw-r--r--src/features/tabs/ui.test.ts52
-rw-r--r--src/features/tabs/ui/tab-flip.ts366
2 files changed, 303 insertions, 115 deletions
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index adfb3fb..c8e07ad 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -425,6 +425,11 @@ describe("TabList", () => {
});
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(() => {
@@ -448,8 +453,13 @@ describe("TabList", () => {
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);
@@ -465,11 +475,15 @@ describe("TabList", () => {
// 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.
- for (const g of ghosts) {
+ // 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(gsap.getTweensOf(g)).toHaveLength(1);
- }
+ 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");
@@ -479,6 +493,24 @@ describe("TabList", () => {
"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 () => {
@@ -487,10 +519,14 @@ describe("TabList", () => {
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.
- for (const id of ["c1", "c2", "g"]) {
- expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)");
- expect(gsap.getTweensOf(card(id))).toHaveLength(1);
+ // 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("");
}
});
});
diff --git a/src/features/tabs/ui/tab-flip.ts b/src/features/tabs/ui/tab-flip.ts
index 2de00ab..ac9daaf 100644
--- a/src/features/tabs/ui/tab-flip.ts
+++ b/src/features/tabs/ui/tab-flip.ts
@@ -1,34 +1,43 @@
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`).
+ * 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: record every `[data-flip-id]`
- * element's position (First), and spawn an inert GHOST for each element the
- * next render drops (a closed tab / dissolved group box).
- * - `play()` — AFTER the DOM updates: run three STAGGERED phases, each only
- * when it has something to do —
+ * - `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 — surviving elements Flip from their old box to their new
- * one (Last → Invert → Play);
+ * 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 — the contents keep their natural size and are
- * revealed / hidden from the bottom edge, never scaled or faded. Grow is a
- * `clip-path` reveal so the grid's layout (already settled by MOVE) never
- * shifts under it.
+ * 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 —
- * `Flip.getState` captures mid-flight positions. Disabled when the user
- * prefers reduced motion (or the environment can't tell, e.g. jsdom).
+ * `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;
@@ -45,18 +54,24 @@ 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 / fully revealed. */
+/** Fully clipped from the bottom edge up (0 visible height). */
const CLIP_HIDDEN = "inset(0% 0% 100% 0%)";
-const CLIP_SHOWN = "inset(0% 0% 0% 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 {
- readonly state: ReturnType<typeof Flip.getState>;
+ /** Visual box (incl. any in-flight motion) per flip id. Cards: the card; boxes: the tint. */
readonly rects: ReadonlyMap<string, DOMRect>;
- readonly ghosted: boolean;
+ /** 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) {
@@ -65,14 +80,20 @@ export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFl
if (scroller === undefined || !motionAllowed()) return;
const els = flipElements(scroller);
if (els.length === 0) return;
- const rects = new Map(els.map((el) => [el.dataset.flipId ?? "", el.getBoundingClientRect()]));
- const byId = new Map(els.map((el) => [el.dataset.flipId ?? "", el]));
- let ghosted = false;
- for (const id of leavingFlipIds([...byId.keys()], nextIds)) {
- const el = byId.get(id);
- if (el !== undefined) ghosted = spawnGhost(scroller, el, isGroupFlipId(id)) || ghosted;
- }
- captured = { state: Flip.getState(els), rects, ghosted };
+
+ // 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() {
@@ -81,61 +102,155 @@ export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFl
const scroller = getScroller();
if (prev === null || scroller === undefined) return;
- const els = flipElements(scroller);
- const staying = els.filter((el) => prev.rects.has(el.dataset.flipId ?? ""));
- const entering = els.filter((el) => !prev.rects.has(el.dataset.flipId ?? ""));
- const moved = staying.some((el) => {
- const before = prev.rects.get(el.dataset.flipId ?? "");
- return before !== undefined && !sameBox(before, el.getBoundingClientRect());
+ // 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() };
});
- // Phase timing: skip a phase that has nothing to animate.
- const moveAt = prev.ghosted ? SHRINK : 0;
- const growAt = moveAt + (moved ? MOVE : 0);
-
- // 3. GROW — hide new elements NOW (through shrink + move), reveal later.
- for (const el of entering) {
- const target = isGroupFlipId(el.dataset.flipId ?? "")
- ? el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") // tint only
- : el;
- if (target === null) continue;
- gsap.killTweensOf(target, "clipPath");
- gsap.fromTo(
- target,
- { clipPath: CLIP_HIDDEN },
+ // 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 },
{
- clipPath: CLIP_SHOWN,
- duration: GROW,
- delay: growAt,
- ease: GROW_EASE,
- clearProps: "clipPath",
+ 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 — Flip holds survivors at their old boxes until `moveAt`.
- if (moved) {
- Flip.from(prev.state, {
- targets: staying,
- duration: MOVE,
- delay: moveAt,
- ease: MOVE_EASE,
- nested: true,
- });
+ // 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);
+ }
+ },
+ },
+ ),
+ );
}
},
};
}
-/** 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 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 {
@@ -149,29 +264,72 @@ 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;
+}
+
/**
- * An inert visual copy of a leaving element, absolutely positioned over its
- * last on-screen rect, that SHRINKS to 0 height (phase 1) and removes itself.
- * The ghost is a clipping frame around a full-size copy, so only the frame's
- * height animates — the copy inside never resizes or reflows.
+ * 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 is appended to the scroller so it shrinks
- * ON TOP of the grid. 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 covers them.
- *
- * Returns whether a ghost was spawned.
+ * 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 spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): boolean {
- const source = isGroup ? el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") : el;
- if (source === null) return false;
+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) return false;
- const r = el.getBoundingClientRect();
+ if (host === null || rect === undefined || (isGroup && source === el)) return null;
const h = host.getBoundingClientRect();
- const copy = source.cloneNode(!isGroup) as HTMLElement;
+ 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 [
@@ -184,13 +342,13 @@ function spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): b
node.removeAttribute(attr);
}
}
- // The copy keeps the element's full size for the whole animation.
Object.assign(copy.style, {
position: "absolute",
+ inset: "auto",
left: "0",
top: "0",
- width: `${r.width}px`,
- height: `${r.height}px`,
+ width: `${plan.width}px`,
+ height: `${plan.height}px`,
margin: "0",
transform: "none",
clipPath: "none",
@@ -201,22 +359,16 @@ function spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): b
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`,
+ left: `${plan.left}px`,
+ top: `${plan.top}px`,
+ width: `${plan.width}px`,
+ height: `${plan.height}px`,
overflow: "hidden",
pointerEvents: "none",
- zIndex: isGroup ? "auto" : "2",
+ zIndex: plan.isGroup ? "auto" : "2",
});
ghost.appendChild(copy);
- if (isGroup) host.prepend(ghost);
- else host.appendChild(ghost);
- gsap.to(ghost, {
- height: 0,
- duration: SHRINK,
- ease: SHRINK_EASE,
- onComplete: () => ghost.remove(),
- });
- return true;
+ if (plan.isGroup) plan.host.prepend(ghost);
+ else plan.host.appendChild(ghost);
+ return ghost;
}