+ {/* Every block below is hold-to-rearrange and hold-to-hide: hold one
+ for a moment and drop it on the stash to bench it (SectionStack).
+ Each section carries its own bottom margin, hence a gapless stack. */}
+ ,
+ },
+ {
+ key: "goal-checklist",
+ label: "Goal checklist",
+ node: project.goal && (
+
+ ),
+ },
+ {
+ key: "goal",
+ label: "GOAL.md",
+ node: project.goal && ,
+ },
+ {
+ key: "key-files",
+ label: "Key files",
+ node: project.keyFiles.length > 0 && (
+
+
+ On {PLAN_LABEL[s.subscription.plan]}{" "}
+ you've paid{" "}
+ {fmtCost(subscriptionPaid())}{" "}
+ over {billingMonths()} month{billingMonths() === 1 ? "" : "s"}. The Conversations
+ dashboard compares that against the API-equivalent cost of everything you've run
+ since {s.subscription.startDate} to show your token savings.
+
+
+ ) : (
+
+ Pick a plan to estimate how much value your subscription returns versus paying per token.
+
+ )}
-
- On {PLAN_LABEL[s.subscription.plan]}{" "}
- you've paid{" "}
- {fmtCost(subscriptionPaid())}{" "}
- over {billingMonths()} month{billingMonths() === 1 ? "" : "s"}. The Conversations
- dashboard compares that against the API-equivalent cost of everything you've run
- since {s.subscription.startDate} to show your token savings.
-
-
- ) : (
-
- Pick a plan to estimate how much value your subscription returns versus paying per token.
-
- )}
-
-
+ ,
+ },
+ ]}
+ />
diff --git a/frontend/src/lib/listOrder.ts b/frontend/src/lib/listOrder.ts
index 8ecfa0a..6a31948 100644
--- a/frontend/src/lib/listOrder.ts
+++ b/frontend/src/lib/listOrder.ts
@@ -54,3 +54,89 @@ export function useListOrder(
return [ordered, commit];
}
+
+/** What {@link useListArrangement} hands straight to ``. */
+export interface ListArrangement {
+ /** The slotted items, in their user order. */
+ items: T[];
+ /** The benched items — rendered as tags in the stash popover. */
+ stash: T[];
+ /** Drop handler: commits a pure reorder. */
+ onReorder: (next: T[]) => void;
+ /** Stash-boundary handler: commits both lists at once. */
+ onStashChange: (next: T[], nextStash: T[]) => void;
+}
+
+/**
+ * {@link useListOrder} plus a **stash**: on top of the order, which items are
+ * benched out of the list entirely. Spread the result into `` and
+ * the group becomes hold-to-rearrange *and* hold-to-hide:
+ *
+ * ```tsx
+ * const arr = useListArrangement("nav", TABS, tabKey);
+ * t.label}>…
+ * ```
+ *
+ * Both lists persist as ids, in the same server-side settings the order uses
+ * (`listOrders` + `listStashes`), and are reconciled the same way: ids the app
+ * no longer ships are dropped, and an item the saved state never heard of is
+ * **slotted**, never benched — shipping a new entry must show it, not hide it.
+ * An item can only be in one of the two lists; the stash wins ties, since the
+ * order bag also keeps a benched item's position for when it comes back.
+ *
+ * @param minSlotted Floor on how many items may stay slotted. `canStash` in the
+ * returned arrangement is not enforced here — the guard is applied on commit,
+ * so a stash-change that would take the group below the floor is ignored.
+ */
+export function useListArrangement(
+ name: string,
+ items: T[],
+ getKey: (item: T) => string,
+ minSlotted = 0,
+): ListArrangement {
+ const savedStash = useSettings((s) => s.listStashes?.[name]);
+ const setListStash = useSettings((s) => s.setListStash);
+ const setListOrder = useSettings((s) => s.setListOrder);
+ const [ordered] = useListOrder(name, items, getKey);
+
+ const { slotted, stash } = useMemo(() => {
+ const benched = new Set(savedStash ?? []);
+ return {
+ slotted: ordered.filter((it) => !benched.has(getKey(it))),
+ // Kept in the saved stash's own order, so the tag row is stable too.
+ stash: (savedStash ?? [])
+ .map((id) => ordered.find((it) => getKey(it) === id))
+ .filter((it): it is T => it !== undefined),
+ };
+ }, [ordered, savedStash, getKey]);
+
+ /**
+ * The order bag keeps *every* item, benched ones included (appended after the
+ * slotted ones), so a tag dragged back out of the stash lands where it used
+ * to be rather than at the end.
+ */
+ const commitBoth = useCallback(
+ (next: T[], nextStash: T[]) => {
+ const benched = new Set(nextStash.map(getKey));
+ const tail = ordered.filter((it) => benched.has(getKey(it)));
+ setListOrder(name, [...next, ...tail].map(getKey));
+ setListStash(name, nextStash.map(getKey));
+ },
+ [getKey, ordered, name, setListOrder, setListStash],
+ );
+
+ const onReorder = useCallback(
+ (next: T[]) => commitBoth(next, stash),
+ [commitBoth, stash],
+ );
+
+ const onStashChange = useCallback(
+ (next: T[], nextStash: T[]) => {
+ if (next.length < minSlotted) return;
+ commitBoth(next, nextStash);
+ },
+ [minSlotted, commitBoth],
+ );
+
+ return { items: slotted, stash, onReorder, onStashChange };
+}
diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts
index c808e4c..e24ff04 100644
--- a/frontend/src/settings.ts
+++ b/frontend/src/settings.ts
@@ -144,6 +144,15 @@ export interface SettingsState {
* renaming or dropping an entry can never strand a list.
*/
listOrders: Record;
+ /**
+ * Which entries of those same lists are **benched** — dragged out of the
+ * group into its stash popover and hidden until dragged back (see
+ * `lib/listOrder.ts`'s `useListArrangement`). Kept beside {@link listOrders}
+ * rather than encoded in it (e.g. as a suffix) so a list can be reordered
+ * and un-stashed independently, and so a client that only knows about order
+ * still reads a complete order.
+ */
+ listStashes: Record;
/**
* The model a spawned session runs on — the `--model` argument carried by the
* composer's model select (a CLI alias like `opus`, or a pinned model id like
@@ -187,6 +196,8 @@ export interface SettingsState {
setAvatar: (patch: Partial) => void;
setListOrder: (name: string, order: string[]) => void;
resetListOrder: (name: string) => void;
+ setListStash: (name: string, stash: string[]) => void;
+ resetListStash: (name: string) => void;
addPromptShortcut: () => void;
updatePromptShortcut: (id: string, patch: Partial) => void;
removePromptShortcut: (id: string) => void;
@@ -227,6 +238,7 @@ export const useSettings = create()(
subscription: { plan: "none", monthlyCost: 0, startDate: "" },
promptShortcuts: DEFAULT_PROMPT_SHORTCUTS.map((s) => ({ ...s })),
listOrders: {},
+ listStashes: {},
spawnModel: "",
spawnHarness: "claude",
spawnThinking: true,
@@ -256,6 +268,14 @@ export const useSettings = create()(
return { listOrders: rest };
}),
+ setListStash: (name, stash) =>
+ set((s) => ({ listStashes: { ...s.listStashes, [name]: stash } })),
+ resetListStash: (name) =>
+ set((s) => {
+ const { [name]: _dropped, ...rest } = s.listStashes ?? {};
+ return { listStashes: rest };
+ }),
+
addPromptShortcut: () =>
set((s) => ({
promptShortcuts: [
diff --git a/frontend/src/technical/VisibilityPopover.tsx b/frontend/src/technical/VisibilityPopover.tsx
index e563dcb..66cbb27 100644
--- a/frontend/src/technical/VisibilityPopover.tsx
+++ b/frontend/src/technical/VisibilityPopover.tsx
@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { Eye, EyeOff, Check } from "lucide-react";
+import { HoldEditable } from "@gabvdl/ui";
import { cn } from "@/lib/utils";
import { VIS_GROUPS, useVisibility, type VisGroup, type VisItem } from "@/lib/visibility";
import { useListOrder } from "@/lib/listOrder";
-import { HoldEditable } from "@/technical/ui/HoldEditable";
/**
* Eye button + popover: switches for everything the conversation page renders
diff --git a/frontend/src/technical/ui/HeaderActions.tsx b/frontend/src/technical/ui/HeaderActions.tsx
new file mode 100644
index 0000000..6cc07d4
--- /dev/null
+++ b/frontend/src/technical/ui/HeaderActions.tsx
@@ -0,0 +1,68 @@
+import type { ReactNode } from "react";
+import { HoldEditable } from "@gabvdl/ui";
+import { useListArrangement } from "@/lib/listOrder";
+
+/** One button (or link, or badge) in a header's action row. */
+export interface HeaderAction {
+ /** Stable id — the persisted arrangement is a list of these. */
+ key: string;
+ /** Short human name: the stash tag's label, and the a11y name of the slot. */
+ label: string;
+ /** The control itself. Rendered as-is; it keeps its own click handler. */
+ node: ReactNode;
+ /**
+ * This action can never be benched. Use for the row's escape hatches (a
+ * back link, a close button) — the ones whose absence would strand the user.
+ */
+ pinned?: boolean;
+}
+
+/**
+ * A header's icon-button row, made hold-to-rearrange **and** hold-to-hide.
+ *
+ * Hold any button for a moment and the row enters edit mode: the buttons jump,
+ * the held one follows the finger, and a stash popover opens under the header.
+ * Drop a button on the popover to bench it — it disappears from the row until
+ * you drag its tag back in. Order and stash both persist server-side (see
+ * `useListArrangement`), so a phone and a desktop show the same row.
+ *
+ * A row is only *reachable* through its own buttons — an empty row has nothing
+ * left to hold, so the stash could never be reopened. {@link MIN_ACTIONS} slots
+ * therefore always stay, and {@link HeaderAction.pinned} actions never leave at
+ * all.
+ *
+ * ```tsx
+ * },
+ * ]} />
+ * ```
+ */
+export const MIN_ACTIONS = 1;
+
+const actionKey = (a: HeaderAction) => a.key;
+
+export function HeaderActions({
+ name,
+ actions,
+ className,
+}: {
+ /** Stable list name for the persisted arrangement, e.g. `header:goals`. */
+ name: string;
+ actions: HeaderAction[];
+ /** Extra classes for the row (it is a flex row by default). */
+ className?: string;
+}) {
+ const arrangement = useListArrangement(name, actions, actionKey, MIN_ACTIONS);
+ return (
+ !a.pinned && arrangement.items.length > MIN_ACTIONS}
+ stashLabel={(a) => a.label}
+ stashPlacement="bottom"
+ className={className ?? "flex shrink-0 items-center gap-0.5"}
+ >
+ {(a) => a.node}
+
+ );
+}
diff --git a/frontend/src/technical/ui/HoldEditable.tsx b/frontend/src/technical/ui/HoldEditable.tsx
deleted file mode 100644
index b358d87..0000000
--- a/frontend/src/technical/ui/HoldEditable.tsx
+++ /dev/null
@@ -1,767 +0,0 @@
-import {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- type ReactNode,
-} from "react";
-import { createPortal } from "react-dom";
-import { cn } from "@/lib/utils";
-
-/**
- * HoldEditable — iOS-springboard-style "hold to rearrange" for small lists.
- *
- * Press and hold any item of the group for {@link HoldEditableProps.holdDelay}
- * (1.4s by default) and it is *picked up*: it lifts out of the flow and follows
- * the pointer, and every other item starts jumping in place (0.8s cycle, each
- * with its own random phase offset, so the group doesn't pulse in lockstep).
- * Dragging into another item's slot hands that slot over: the displaced items
- * glide out of the way and the held item's slot — a jumping ghost outline —
- * follows the pointer. Releasing drops the held item into that slot, ends edit
- * mode and commits the new order via `onReorder`.
- *
- * **The DOM order never changes during a drag.** Slots are measured once at
- * pickup and the rearrangement is expressed purely as transforms; only the drop
- * commits a real reorder, once the pointer is gone. That is not an optimisation
- * — it is what makes the drag work at all. Reordering the list live means React
- * moves the pressed node (and re-renders whichever item now occupies a
- * position-dependent slot), and a browser cancels the touch whose target was
- * detached: the drag died on the first hand-over. Freezing the DOM also means
- * slot geometry is fixed, so hit-testing needs no re-measuring and no animation
- * lock, and a fast drag can't outrun the shuffle.
- *
- * It is layout-agnostic on purpose: the group container gets whatever flex/grid
- * classes the caller passes, so the same component reorders a horizontal
- * navbar, a vertical panel of links, a column of cards — or a wrapping grid.
- * A grid is detected from the measured slots (several distinct rows *and*
- * columns) and switches the reflow to 2D slot-hopping: each displaced item
- * glides to the rect of the slot it now occupies, instead of shifting along a
- * single axis. Grid cells are assumed roughly equal-sized. It only ever
- * renders one wrapper `
` per item.
- *
- * ```tsx
- * t.id}
- * onReorder={(next) => save(next.map((t) => t.id))}
- * className="flex items-stretch justify-between"
- * >
- * {(tab, { editing }) => }
- *
- * ```
- */
-
-/** How long the reorder glide (items shuffling into their new slots) runs, in ms. */
-const SHUFFLE_MS = 220;
-/** How long the picked-up item takes to land in its slot on release, in ms. */
-const DROP_MS = 220;
-/**
- * Finger travel (px) that cancels a pending hold — that's a scroll, not a hold.
- * Touch and pen only: a mouse can't scroll with the button down (see `onMove`).
- */
-const MOVE_CANCEL_PX = 12;
-/** How far into a slot the pointer must reach to claim it (fraction of its extent). */
-const CROSS_FRACTION = 0.45;
-/** Clicks fired within this window after a drop are swallowed (see below). */
-const CLICK_SUPPRESS_MS = 500;
-/**
- * Sub-trees a press must not turn into a pickup: native text entry (a hold
- * there means "select text"), plus an explicit `data-hold-editable-ignore`
- * escape hatch for anything else the caller wants to keep pressable.
- */
-const NO_HOLD_SELECTOR =
- "input, textarea, select, [contenteditable=''], [contenteditable='true'], [data-hold-editable-ignore]";
-
-export interface HoldEditableItemState {
- /** This item is the one picked up and following the pointer. */
- held: boolean;
- /** The group is in edit mode (some item is being held). */
- editing: boolean;
- /** The pointer is down on this item, waiting out the hold delay. */
- pressing: boolean;
- /**
- * The item's index. While a drag is in flight this is its **live slot** —
- * where it would land right now — not its position in the DOM.
- */
- index: number;
- /** Number of items in the group. */
- count: number;
-}
-
-export interface HoldEditableProps {
- /** The items, in their current order. */
- items: T[];
- /** Stable identity for an item — also the React key. */
- getKey: (item: T) => string;
- /** Called once, on drop, with the reordered items. Not called if nothing moved. */
- onReorder: (items: T[]) => void;
- /** Renders one item. Receives its live drag state (see {@link HoldEditableItemState}). */
- children: (item: T, state: HoldEditableItemState) => ReactNode;
- /** Classes for the group container — this is where the layout lives. */
- className?: string;
- /** Classes for each item's wrapper (rarely needed; the item renders its own box). */
- itemClassName?: string;
- /** Hold duration before an item is picked up, in ms. */
- holdDelay?: number;
- /** Jump cycle of the non-held items while editing, in ms. */
- jumpInterval?: number;
- /** Turns the whole interaction off — items render, nothing is draggable. */
- disabled?: boolean;
- /** Fired when an item is picked up. */
- onEditStart?: () => void;
- /** Fired when the pointer is released (or the drag is cancelled). */
- onEditEnd?: () => void;
-}
-
-interface Rect {
- left: number;
- top: number;
- width: number;
- height: number;
-}
-
-/**
- * Everything frozen at pickup. `dom` is the DOM order for the whole drag;
- * `order` is the live arrangement the drop will commit; `home` is each slot's
- * geometry **relative to the group container**, so a page or panel that scrolls
- * mid-drag doesn't invalidate it.
- */
-interface Arrangement {
- dom: string[];
- order: string[];
- home: Rect[];
- horizontal: boolean;
- /** The slots wrap into a real grid — reflow is 2D slot-hopping, not axis shifts. */
- grid: boolean;
-}
-
-interface DragState {
- key: string;
- /** Size of the picked-up item, frozen at pickup. */
- w: number;
- h: number;
- /** Where inside the item the pointer grabbed it. */
- grabX: number;
- grabY: number;
- /** Live pointer position, in viewport coordinates. */
- x: number;
- y: number;
-}
-
-interface DropState {
- key: string;
- w: number;
- h: number;
- /** Viewport position of the slot the item is landing in. */
- x: number;
- y: number;
-}
-
-const STYLE_ID = "hold-editable-styles";
-
-/**
- * The keyframes live in a stylesheet rather than inline styles because a
- * `@keyframes` rule can't be expressed inline, and pulling in a CSS file would
- * make the component non-portable. Injected once, on first mount.
- */
-const CSS = `
-@keyframes hold-editable-jump {
- 0% { transform: translateY(0) rotate(0deg); }
- 8% { transform: translateY(-24%) rotate(-1.2deg); }
- 17% { transform: translateY(0) rotate(0deg); }
- 24% { transform: translateY(-8%) rotate(0.8deg); }
- 31% { transform: translateY(0) rotate(0deg); }
- 100% { transform: translateY(0) rotate(0deg); }
-}
-[data-hold-editable-item] {
- -webkit-touch-callout: none;
- -webkit-user-select: none;
- user-select: none;
-}
-[data-hold-editable-dragging] {
- cursor: grabbing;
-}
-@media (prefers-reduced-motion: reduce) {
- .hold-editable-jump { animation: none !important; }
-}
-`;
-
-function useHoldEditableStyles(): void {
- useEffect(() => {
- if (document.getElementById(STYLE_ID)) return;
- const el = document.createElement("style");
- el.id = STYLE_ID;
- el.textContent = CSS;
- document.head.appendChild(el);
- }, []);
-}
-
-const rectOf = (el: Element): Rect => {
- const r = el.getBoundingClientRect();
- return { left: r.left, top: r.top, width: r.width, height: r.height };
-};
-
-const hits = (r: Rect, x: number, y: number) =>
- x >= r.left && x <= r.left + r.width && y >= r.top && y <= r.top + r.height;
-
-/**
- * How much a picked-up item swells. A fixed ratio reads as a tasteful lift on a
- * 64px navbar tab and as a jarring 100px growth spurt on a full-width card, so
- * scale the *absolute* overhang instead and clamp it.
- */
-const liftScale = (w: number, h: number) =>
- 1 + Math.min(0.08, Math.max(0.02, 8 / Math.max(w, h, 1)));
-
-/**
- * Which way the group runs, read off the first two slots that sit apart. Rows
- * and columns need the reflow computed on different axes, and this beats asking
- * the caller to declare an orientation it already expressed in its CSS.
- */
-function isHorizontal(rects: Rect[]): boolean {
- for (let i = 1; i < rects.length; i++) {
- const dx = Math.abs(rects[i].left - rects[0].left);
- const dy = Math.abs(rects[i].top - rects[0].top);
- if (dx > 1 || dy > 1) return dx > dy;
- }
- return true;
-}
-
-/**
- * Whether the slots form a wrapping grid (several distinct rows AND columns).
- * A grid can't reflow along one axis; instead items hop between the frozen
- * slot rects, so the transform model switches to 2D (see `offsets` below).
- * 4px buckets absorb sub-pixel layout jitter.
- */
-function isGrid(rects: Rect[]): boolean {
- const xs = new Set();
- const ys = new Set();
- for (const r of rects) {
- xs.add(Math.round(r.left / 4));
- ys.add(Math.round(r.top / 4));
- }
- return xs.size > 1 && ys.size > 1;
-}
-
-/**
- * Where every item sits, along the main axis, for a given order — the layout a
- * real reflow would produce, derived from the frozen slot geometry. Each item
- * keeps its own size and the original inter-slot gaps are preserved, so a list
- * of unequal items (an expanded card among collapsed ones) still lands right.
- */
-function positionsFor(order: string[], a: Arrangement): Map {
- const { dom, home, horizontal } = a;
- const sizeOf = new Map();
- dom.forEach((k, i) => sizeOf.set(k, horizontal ? home[i].width : home[i].height));
-
- const out = new Map();
- let p = horizontal ? home[0].left : home[0].top;
- for (let j = 0; j < order.length; j++) {
- out.set(order[j], p);
- const size = sizeOf.get(order[j]) ?? 0;
- const gap =
- j + 1 < home.length
- ? horizontal
- ? home[j + 1].left - (home[j].left + home[j].width)
- : home[j + 1].top - (home[j].top + home[j].height)
- : 0;
- p += size + gap;
- }
- return out;
-}
-
-export function HoldEditable({
- items,
- getKey,
- onReorder,
- children,
- className,
- itemClassName,
- holdDelay = 1400,
- jumpInterval = 800,
- disabled = false,
- onEditStart,
- onEditEnd,
-}: HoldEditableProps) {
- useHoldEditableStyles();
-
- const [arrangement, setArrangement] = useState(null);
- const [pressKey, setPressKey] = useState(null);
- const [drag, setDrag] = useState(null);
- const [drop, setDrop] = useState(null);
-
- const byKey = useMemo(() => {
- const m = new Map();
- for (const it of items) m.set(getKey(it), it);
- return m;
- }, [items, getKey]);
-
- // While a drag is in flight we render the frozen DOM order; otherwise the
- // parent's. The frozen order is dropped if it no longer describes exactly the
- // items we were handed (the parent may add or remove one mid-drag).
- const list = useMemo(() => {
- const dom = arrangement?.dom;
- if (!dom || dom.length !== items.length) return items;
- if (dom.some((k) => !byKey.has(k))) return items;
- return dom.map((k) => byKey.get(k)!);
- }, [arrangement, items, byKey]);
-
- const keys = useMemo(() => list.map(getKey), [list, getKey]);
- const keysRef = useRef(keys);
- keysRef.current = keys;
-
- const containerRef = useRef(null);
- /** Wrapper element per key — the measurable "slot". */
- const slots = useRef(new Map());
- /** Order at pickup, so Escape / pointercancel can put everything back. */
- const orderAtPickup = useRef([]);
- /** Timestamp of the last drop — see the click-suppression effect below. */
- const droppedAt = useRef(0);
- const holdTimer = useRef(null);
- const dropTimer = useRef(null);
- const dragRef = useRef(null);
- dragRef.current = drag;
- const arrangementRef = useRef(null);
- arrangementRef.current = arrangement;
- const pressPos = useRef({ x: 0, y: 0 });
- /** Last pointer position seen during a drag, in viewport coordinates. */
- const lastPointer = useRef({ x: 0, y: 0 });
- /** Pointer that started the press — needed to capture it at pickup. */
- const pointerId = useRef(null);
-
- /** Per-item jump phase, so the group jumps out of sync. Stable per key. */
- const jumpPhase = useRef(new Map());
- const phaseOf = (key: string) => {
- let p = jumpPhase.current.get(key);
- if (p === undefined) {
- p = -Math.random() * jumpInterval;
- jumpPhase.current.set(key, p);
- }
- return p;
- };
-
- const clearHoldTimer = () => {
- if (holdTimer.current !== null) {
- window.clearTimeout(holdTimer.current);
- holdTimer.current = null;
- }
- };
-
- /* ---------------------------------------------------------------- pickup */
-
- const beginDrag = useCallback(
- (key: string, x: number, y: number) => {
- const el = slots.current.get(key);
- const container = containerRef.current;
- if (!el || !container) return;
-
- // Measure every slot once, relative to the container. These rects are the
- // drop hitboxes and the layout model for the rest of the drag; nothing
- // re-measures, because nothing in the DOM moves.
- const c = rectOf(container);
- const dom = [...keysRef.current];
- const home: Rect[] = [];
- for (const k of dom) {
- const slot = slots.current.get(k);
- if (!slot) return;
- const r = rectOf(slot);
- home.push({ left: r.left - c.left, top: r.top - c.top, width: r.width, height: r.height });
- }
-
- const r = rectOf(el);
- // Capture the pointer on the slot wrapper: with the DOM frozen it stays
- // put for the whole drag, so every later event is guaranteed to reach us
- // even if the item's own content re-renders.
- if (pointerId.current !== null) {
- try {
- el.setPointerCapture(pointerId.current);
- } catch {
- /* pointer already gone — the drag will end on the next event */
- }
- }
- lastPointer.current = { x, y };
- orderAtPickup.current = dom;
- setArrangement({
- dom,
- order: [...dom],
- home,
- horizontal: isHorizontal(home),
- grid: isGrid(home),
- });
- setPressKey(null);
- setDrag({ key, w: r.width, h: r.height, grabX: x - r.left, grabY: y - r.top, x, y });
- navigator.vibrate?.(12);
- onEditStart?.();
- },
- [onEditStart],
- );
-
- const onPointerDown = (e: React.PointerEvent, key: string) => {
- if (disabled || dragRef.current || drop) return;
- if (e.pointerType === "mouse" && e.button !== 0) return;
- // Text fields (and anything explicitly opted out) keep their own press
- // semantics: holding inside a textarea must select text, not lift the card.
- const target = e.target as Element | null;
- if (target?.closest?.(NO_HOLD_SELECTOR)) return;
- pressPos.current = { x: e.clientX, y: e.clientY };
- lastPointer.current = { x: e.clientX, y: e.clientY };
- pointerId.current = e.pointerId;
- setPressKey(key);
- clearHoldTimer();
- holdTimer.current = window.setTimeout(() => {
- holdTimer.current = null;
- // Pick up wherever the pointer is *now*, not where it went down: over
- // 1.4s a mouse drifts, and starting the drag at a stale point would make
- // the item jump out from under the cursor.
- beginDrag(key, lastPointer.current.x, lastPointer.current.y);
- }, holdDelay);
- };
-
- /* ------------------------------------------------------------ reordering */
-
- /**
- * Hit-test the pointer against the frozen slots and re-file the held item if
- * it has moved into someone else's. The held item is *moved* to that index
- * (the items in between shift by one) rather than swapped with it: for the
- * neighbour-by-neighbour hops a real drag produces the two are the same, but
- * only "move" converges on the slot the pointer is over when a fast drag
- * crosses several at once.
- */
- const considerMove = useCallback(() => {
- const d = dragRef.current;
- const a = arrangementRef.current;
- const container = containerRef.current;
- if (!d || !a || !container) return;
-
- // Slot geometry is frozen, but the group itself can scroll or reflow under
- // the pointer — one container rect per move is enough to stay exact.
- const c = rectOf(container);
- const x = lastPointer.current.x - c.left;
- const y = lastPointer.current.y - c.top;
-
- const held = a.order.indexOf(d.key);
- const over = a.home.findIndex((r) => hits(r, x, y));
- if (over < 0 || held < 0 || over === held) return;
-
- // Hysteresis: the pointer has to travel a little way into the target before
- // it takes the slot over, so items of unequal size can't ping-pong on the
- // boundary. The threshold sits just *short* of the middle on purpose —
- // aiming at the centre of the item you want is the most natural thing to
- // do, and an exact midpoint rule turns that into a coin flip that leaves
- // the item one slot short. (A grid needs none of this: its hitboxes are
- // the frozen cell rects themselves, so a claim can't oscillate.)
- if (!a.grid) {
- const r = a.home[over];
- const pos = a.horizontal ? x : y;
- const near = a.horizontal ? r.left : r.top;
- const span = a.horizontal ? r.width : r.height;
- const threshold = near + span * (over > held ? CROSS_FRACTION : 1 - CROSS_FRACTION);
- if (over > held ? pos < threshold : pos > threshold) return;
- }
-
- const next = [...a.order];
- next.splice(over, 0, next.splice(held, 1)[0]);
- setArrangement({ ...a, order: next });
- }, []);
-
- /* --------------------------------------------------------- drag lifetime */
-
- const endDrag = useCallback(
- (cancelled: boolean) => {
- clearHoldTimer();
- setPressKey(null);
- const d = dragRef.current;
- const a = arrangementRef.current;
- if (!d || !a) return;
-
- const finalOrder = cancelled ? orderAtPickup.current : a.order;
- const settled: Arrangement = { ...a, order: finalOrder };
- setArrangement(settled);
-
- // Land the ghost on the slot the item is taking, in viewport coordinates.
- const c = containerRef.current ? rectOf(containerRef.current) : { left: 0, top: 0 };
- let x: number;
- let y: number;
- if (a.grid) {
- const slot = a.home[finalOrder.indexOf(d.key)];
- x = c.left + slot.left;
- y = c.top + slot.top;
- } else {
- const pos = positionsFor(finalOrder, settled).get(d.key) ?? 0;
- const homeRect = a.home[a.dom.indexOf(d.key)];
- x = a.horizontal ? c.left + pos : c.left + homeRect.left;
- y = a.horizontal ? c.top + homeRect.top : c.top + pos;
- }
-
- setDrag(null);
- setDrop({ key: d.key, w: d.w, h: d.h, x, y });
- droppedAt.current = Date.now();
-
- if (dropTimer.current !== null) window.clearTimeout(dropTimer.current);
- dropTimer.current = window.setTimeout(() => {
- dropTimer.current = null;
- setDrop(null);
- // Only now does the DOM reorder — into exactly the arrangement the
- // transforms were already showing, so the swap is invisible.
- setArrangement(null);
- }, DROP_MS);
-
- const changed =
- !cancelled && finalOrder.some((k, i) => k !== orderAtPickup.current[i]);
- if (changed) {
- const next = finalOrder.map((k) => byKey.get(k)).filter(Boolean) as T[];
- if (next.length === finalOrder.length) onReorder(next);
- }
- onEditEnd?.();
- },
- [byKey, onReorder, onEditEnd],
- );
-
- // Window-level pointer handling: a drag must survive the pointer leaving the
- // item (and the group) entirely.
- useEffect(() => {
- if (!pressKey && !drag) return;
-
- const onMove = (e: PointerEvent) => {
- const d = dragRef.current;
- if (!d) {
- // Still waiting out the hold. Travel only cancels it for a *finger*,
- // where it means "I'm scrolling, not holding". A mouse can't scroll
- // with the button down, and a hand resting on one drifts well past any
- // sane threshold over 1.4s — cancelling on that made hold-to-drag
- // essentially impossible on desktop. So the mouse keeps holding, and
- // the pickup simply happens wherever the cursor ended up.
- lastPointer.current = { x: e.clientX, y: e.clientY };
- if (e.pointerType === "mouse") return;
- const dx = e.clientX - pressPos.current.x;
- const dy = e.clientY - pressPos.current.y;
- if (Math.hypot(dx, dy) > MOVE_CANCEL_PX) {
- clearHoldTimer();
- setPressKey(null);
- }
- return;
- }
- lastPointer.current = { x: e.clientX, y: e.clientY };
- setDrag({ ...d, x: e.clientX, y: e.clientY });
- considerMove();
- };
- const onUp = () => endDrag(false);
- const onCancel = () => (dragRef.current ? endDrag(true) : setPressKey(null));
- const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape" && dragRef.current) endDrag(true);
- };
- // Once an item is picked up the finger owns it: stop the page scrolling
- // under it. Must be non-passive to be allowed to preventDefault.
- const onTouchMove = (e: TouchEvent) => {
- if (dragRef.current) e.preventDefault();
- };
-
- window.addEventListener("pointermove", onMove);
- window.addEventListener("pointerup", onUp);
- window.addEventListener("pointercancel", onCancel);
- window.addEventListener("keydown", onKey);
- window.addEventListener("touchmove", onTouchMove, { passive: false });
- return () => {
- window.removeEventListener("pointermove", onMove);
- window.removeEventListener("pointerup", onUp);
- window.removeEventListener("pointercancel", onCancel);
- window.removeEventListener("keydown", onKey);
- window.removeEventListener("touchmove", onTouchMove);
- };
- }, [pressKey, drag, endDrag, considerMove]);
-
- // A hold-then-release still fires a click on whatever was under the pointer.
- // These items are usually links or buttons, so swallow the click that closes
- // a drag — reordering the navbar must not also navigate.
- useEffect(() => {
- const onClick = (e: MouseEvent) => {
- if (Date.now() - droppedAt.current > CLICK_SUPPRESS_MS) return;
- droppedAt.current = 0;
- e.preventDefault();
- e.stopPropagation();
- };
- window.addEventListener("click", onClick, true);
- return () => window.removeEventListener("click", onClick, true);
- }, []);
-
- useEffect(
- () => () => {
- clearHoldTimer();
- if (dropTimer.current !== null) window.clearTimeout(dropTimer.current);
- },
- [],
- );
-
- /* ------------------------------------------------------------- rendering */
-
- const editing = drag !== null;
- const ghostKey = drag?.key ?? drop?.key ?? null;
- const ghostItem = ghostKey ? byKey.get(ghostKey) : undefined;
-
- // Where each item currently sits, versus where its DOM node actually is.
- // Rows and columns shift along their axis; a grid hops between slot rects.
- const offsets = useMemo(() => {
- if (!arrangement || arrangement.dom.length !== arrangement.order.length) return null;
- const out = new Map();
- if (arrangement.grid) {
- arrangement.dom.forEach((k, i) => {
- const j = arrangement.order.indexOf(k);
- const target = arrangement.home[j] ?? arrangement.home[i];
- out.set(k, {
- x: target.left - arrangement.home[i].left,
- y: target.top - arrangement.home[i].top,
- });
- });
- return out;
- }
- const pos = positionsFor(arrangement.order, arrangement);
- arrangement.dom.forEach((k, i) => {
- const home = arrangement.horizontal ? arrangement.home[i].left : arrangement.home[i].top;
- const shift = (pos.get(k) ?? home) - home;
- out.set(k, arrangement.horizontal ? { x: shift, y: 0 } : { x: 0, y: shift });
- });
- return out;
- }, [arrangement]);
-
- const slotIndex = (key: string) =>
- arrangement ? arrangement.order.indexOf(key) : keys.indexOf(key);
-
- return (
- <>
-
{
- if (el) slots.current.set(key, el);
- else slots.current.delete(key);
- }}
- draggable={false}
- onDragStart={(e) => e.preventDefault()}
- onContextMenu={(e) => {
- // A long press pops the OS context menu / selection callout on
- // touch, which would fight the pickup.
- if (pressing || editing) e.preventDefault();
- }}
- onPointerDown={(e) => onPointerDown(e, key)}
- className={itemClassName}
- style={
- arrangement && shift
- ? {
- position: "relative",
- transform: `translate(${shift.x}px, ${shift.y}px)`,
- transition: `transform ${SHUFFLE_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`,
- }
- : undefined
- }
- >
- {/* The item's own subtree is rendered in every state and is never
- swapped out — a held item is only made invisible, and it keeps
- rendering with exactly the props it had before the pickup.
- That last part matters: a caller whose markup depends on the
- index (the navbar's centre tab is a different element) would
- otherwise have this very node — the one the pointer went down
- on — unmounted the moment the held item changed slot, and the
- browser cancels a touch whose target left the document. The
- copy is invisible, so freezing it costs nothing. */}
-
-
- {/* Where the held item will land: a jumping outline over its slot. */}
- {held && (
-
- )}
-
- );
- })}
-
-
- {/* The picked-up item, lifted out of the flow into a body-level layer so
- no `overflow: hidden` ancestor can clip it while it roams. */}
- {ghostItem !== undefined &&
- typeof document !== "undefined" &&
- createPortal(
-
,
- document.body,
- )}
- >
- );
-}
diff --git a/frontend/src/technical/ui/SectionStack.tsx b/frontend/src/technical/ui/SectionStack.tsx
new file mode 100644
index 0000000..2fc8ebd
--- /dev/null
+++ b/frontend/src/technical/ui/SectionStack.tsx
@@ -0,0 +1,62 @@
+import type { ReactNode } from "react";
+import { HoldEditable } from "@gabvdl/ui";
+import { useListArrangement } from "@/lib/listOrder";
+
+/** One block of a page: a card, a table, a README — anything stack-shaped. */
+export interface PageSection {
+ /** Stable id — the persisted arrangement is a list of these. */
+ key: string;
+ /** Short human name: the stash tag's label. */
+ label: string;
+ /** The section itself, or `null`/`false` when this page has nothing to show. */
+ node: ReactNode;
+}
+
+/**
+ * A page's stack of sections, made hold-to-rearrange **and** hold-to-hide.
+ *
+ * Same gesture as {@link HeaderActions}, one altitude up: hold a section for a
+ * moment, the stack enters edit mode, and a popover of tags opens beside it.
+ * Drop a section on the popover to bench it — the Service page's Traefik
+ * routers are fascinating exactly once — and drag its tag back to bring it
+ * home. Order and stash persist server-side per {@link name}, so the same page
+ * looks the same on a phone.
+ *
+ * At least {@link MIN_SECTIONS} section always stays: the stack is only
+ * reachable through its own sections, so an empty one could never be reopened.
+ *
+ * Sections a page can't render (no commits, no GOAL.md) are dropped here rather
+ * than by the caller, so the list reads as the page's full repertoire and a
+ * section that comes back later returns to its saved slot.
+ */
+export const MIN_SECTIONS = 1;
+
+const sectionKey = (s: PageSection) => s.key;
+
+export function SectionStack({
+ name,
+ sections,
+ className,
+}: {
+ /** Stable list name for the persisted arrangement, e.g. `sections:service`. */
+ name: string;
+ sections: PageSection[];
+ className?: string;
+}) {
+ const present = sections.filter((s) => s.node);
+ const arrangement = useListArrangement(name, present, sectionKey, MIN_SECTIONS);
+ return (
+ arrangement.items.length > MIN_SECTIONS}
+ stashLabel={(s) => s.label}
+ // The popover hangs above the stack and flips/pins itself into view when
+ // the stack is taller than the screen (@gabvdl/ui ≥ 0.30.3).
+ stashPlacement="top"
+ className={className ?? "space-y-4"}
+ >
+ {(s) => s.node}
+
+ );
+}