summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--notes/pinned-tiles-plan.md129
-rw-r--r--src/features/workspaces/index.ts7
-rw-r--r--src/features/workspaces/logic/drag-controller.test.ts39
-rw-r--r--src/features/workspaces/logic/drag-controller.ts19
-rw-r--r--src/features/workspaces/logic/tile-layout.test.ts428
-rw-r--r--src/features/workspaces/logic/tile-layout.ts335
-rw-r--r--src/features/workspaces/tile-layout-store.svelte.ts61
-rw-r--r--src/features/workspaces/tile-layout-store.test.ts77
-rw-r--r--src/features/workspaces/ui/HomeHeader.svelte2
-rw-r--r--src/features/workspaces/ui/HomeHeader.test.ts2
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.svelte318
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.test.ts43
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte2
13 files changed, 1194 insertions, 268 deletions
diff --git a/notes/pinned-tiles-plan.md b/notes/pinned-tiles-plan.md
new file mode 100644
index 0000000..6d50e55
--- /dev/null
+++ b/notes/pinned-tiles-plan.md
@@ -0,0 +1,129 @@
+# Pinned tiles + displacement reflow — plan
+
+Feature: home tiles can be dropped onto **specific cells** of the grid; empty cells are
+real estate that persists. Dragging shows a live **FLIP** reflow preview; Escape/cancel
+returns everything to the committed layout.
+
+## Interaction semantics (user-specified, literal)
+
+The grid's cells form a fixed stream (wide: column-major over 4 rows; tall: row-major
+over 2 columns). Only **filled** cells flow. Insertion at a cell pushes the contiguous
+run of filled cells **forward** (in stream order) until the first empty cell absorbs it:
+
+- `X _ X` + place Y at slot 1 → `Y X X` (X shifts into the gap; the second X never moves)
+- `_ X Z _` + place Y at slot 2 → `_ Y X Z` (X and Z each shift one; leading gap kept)
+
+Rules that follow (and are deliberately uniform):
+
+1. **Gaps never collapse.** Removing a tile opens a gap at its anchor; nothing slides
+ backward to fill it. So an internal move = remove (gap opens) + insert (run pushes
+ forward): `X Y Z`, drag X to slot 3 → `_ Y X Z`. The gap at slot 1 is preserved —
+ consistent with the two examples above.
+2. **Preview = the exact post-drop state, recomputed from the COMMITTED layout on every
+ hover-cell change** ("as if the user directly moved from the original to there" — no
+ accumulation of intermediate previews). The animation, however, runs from wherever
+ tiles currently are on screen (mid-flight included) to the new targets — re-entrant
+ FLIP.
+3. **Escape / pointercancel** discards the preview: the dragged tile flies back to its
+ committed anchor and everything FLIPs home. Nothing persists.
+4. During a drag: a **faint dot** marks the center of every unit cell (incl. one extra
+ pitch of slack past the content end), and the **dragged tile element itself follows
+ the pointer** (lifted: scale + shadow, no pointer events). A dashed ghost footprint
+ marks the landing anchor.
+
+## Data model (v2)
+
+```ts
+TileLayoutEntry { id, colSpan, rowSpan, order, col?, row? } // col/row: pinned anchor
+TileLayoutData { version: 2, tiles } // sanitize migrates v1→v2
+```
+
+- Anchors are persisted in a **universal 4-row frame** (wide's frame). Tall normalizes:
+ render `{ c, r }` ↔ universal `{ col: 1, row: 2(r−1)+c }`. Wide is identity.
+ A pin outside tall's 2 columns reads as **unpinned** in tall (render-time clamp — same
+ philosophy as spans). Round-trips can be lossy in that ragged case; accepted.
+- `col`/`row` absent = **unpinned** → flows into the first free cells at render time.
+- `order` remains: arrival order for unpinned flow + first-wins tie-breaks. Sort-by-recent
+ now also **clears pins** (an explicit arrangement reset).
+- v1 documents load as all-unpinned (render identical to today) and write back as v2 on
+ the next mutation. No version sniffing outside `sanitizeLayout`.
+
+## Pure ops (`logic/tile-layout.ts`)
+
+- `pack(entries, geometry)` — v2: place in-range pinned tiles first (universal→render
+ anchor; out-of-range/overlapping/span-unfit → deferred to unpinned), then flow unpinned
+ by first-fit. One pass, no solver; first-pinned-wins, strictly one-ply (a displaced pin
+ never bumps another pin).
+- `displacePlace(placements, draggedId, cell, geometry)` — the render-frame core:
+ remove the dragged tile (gap opens), force-anchor it at `cell`, then walk the others in
+ stream order: keep if free, else re-anchor at the next fitting slot **forward** of the
+ conflict (never backward). Used for BOTH the live preview (on packed placements) and,
+ via `placeAtCell`, the commit.
+- `placeAtCell(entries, id, cell, geometry)` — commit path: pack → displacePlace →
+ pin back to entries (universal-frame anchors; persisted spans kept raw, anchors come
+ from the render placements).
+- `pinAtFirstEmpty(entries, id, geometry)` — the create flow: pin the tile at the first
+ truly-empty slot (holes now count).
+- `firstEmptySlot` — unchanged contract, now hole-aware by construction.
+- `sortByRecentActivity` — renumbers order AND clears pins.
+
+## Store (`tile-layout-store.svelte.ts`)
+
+- `move(id, toIndex)` → replaced by `placeAtCell(id, cell, geometry)`.
+- Structural equality (`sameEntries`) gains `col`/`row`.
+
+## Drag (`logic/drag-controller.ts`)
+
+- `targetIndex: number` → `targetCell: { col, row } | null` (pure rename; semantics of
+ the state machine — tap/lift/drop/cancel, touch scroll-wins — unchanged).
+
+## Component (`HomeTileGrid.svelte`)
+
+- **Hit math** replaces `elementFromPoint`: cell = `floor((pointer − gridRect.origin) /
+ pitch) + 1` (pitch = unit + gap), clamped to ≥ 1 and to the fixed cross-axis. The gap
+ between cells maps to the preceding cell. The pointer always resolves to a cell during
+ a drag (clamped), so empty cells are drop targets.
+- **Dots overlay**: while dragging, the grid gets a `radial-gradient` background
+ (2px dot, `base-content` at low opacity) with `background-size = pitch × pitch` and
+ `background-position = unit/2` so dots sit at cell centers; `padding` extends one pitch
+ past the content end so the append-slot has a dot. Pure CSS, no extra DOM.
+- **Dragged tile follows the pointer**: the wrapper keeps its committed grid slot in the
+ layout; a `translate(pointer − grabOffset) scale(1.05)` transform (via gsap) lifts it
+ out visually; `pointer-events: none` + pointer capture keep the events flowing.
+- **Ghost**: dashed outline (dragged tile's tone) at the preview anchor, footprint-sized.
+- **FLIP**: GSAP `Flip` plugin. Capture in `$effect.pre` (state of all tile elements
+ except the dragged/settling one), mutate (preview placements / committed placements),
+ `Flip.from(state, …)` in `$effect` after the DOM update. Re-entrancy comes free:
+ `Flip.getState` records in-flight transforms, so each hover change animates from the
+ current visual position — while the preview itself is always recomputed from COMMITTED
+ (semantics vs. motion stay separate, per the user's requirement).
+- **Drop**: commit `placeAtCell`, then settle the tile from its pointer transform to
+ identity (gsap, ~150ms) — it lands exactly where the ghost was.
+- **Escape**: window `keydown` in the CAPTURE phase while dragging (beats the page-level
+ Escape→deselect handler, `stopPropagation`): dispatch `cancel` → preview discards →
+ tiles FLIP home → dragged tile gsap-animates from the pointer back to its anchor.
+
+## Out of scope / non-goals
+
+- No backward displacement (runs only push forward); no multi-tile drag; no touch
+ auto-scroll while dragging (existing scroll-wins semantics stay).
+- The resize picker is unchanged: resizing a pinned tile may push it to unpinned flow if
+ the footprint no longer fits its anchor (render-time rule — persisted spans untouched).
+
+## Files
+
+1. `src/features/workspaces/logic/tile-layout.ts` (+ `tile-layout.test.ts`) — the bulk
+2. `src/features/workspaces/tile-layout-store.svelte.ts` (+ test)
+3. `src/features/workspaces/logic/drag-controller.ts` (+ test) — mechanical targetCell rename
+4. `src/features/workspaces/ui/HomeTileGrid.svelte` — hit math, dots, follow-pointer, ghost, Flip
+5. `src/features/workspaces/ui/WorkspacesHome.svelte` (+ test) — wire placeAtCell; sort label
+6. `src/features/workspaces/ui/HomeHeader.svelte` — sort label copy
+
+## Verification
+
+- New pure tests: v2 sanitize/migration, pinned pack (incl. wide↔tall mapping, overlap
+ first-wins, out-of-range → flow), displacement (both user examples + internal moves +
+ span cases), pin round-trip, hole-aware firstEmptySlot, store equality.
+- Component tests: Escape cancels a drag without persisting; drop commits `placeAtCell`.
+- `bun run typecheck && bun run test && bun run build`; biome clean on touched files.
+- Headless screenshots (wide + tall) of a pinned layout with gaps.
diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts
index 0aa7fca..243357e 100644
--- a/src/features/workspaces/index.ts
+++ b/src/features/workspaces/index.ts
@@ -23,17 +23,20 @@ export type {
} from "./logic/tile-layout";
export {
clampSpan,
+ displacePlace,
fillOrderKey,
firstEmptySlot,
geometryFor,
- insertAtFirstEmptySlot,
mergeLayout,
- moveTile,
pack,
+ pinAtFirstEmpty,
+ placeAtCell,
resizeTile,
sanitizeLayout,
sortByRecentActivity,
sortTiles,
+ toRenderCell,
+ toUniversalCell,
} from "./logic/tile-layout";
export { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./logic/view-model";
export type { WorkspaceStore } from "./store.svelte";
diff --git a/src/features/workspaces/logic/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts
index d6dd760..459063a 100644
--- a/src/features/workspaces/logic/drag-controller.test.ts
+++ b/src/features/workspaces/logic/drag-controller.test.ts
@@ -26,7 +26,7 @@ describe("nextDragPhase — mouse", () => {
it("movement past the threshold starts a drag", () => {
const transition = nextDragPhase(
press(),
- { type: "pointer-move", x: 20, y: 12, targetIndex: 3 },
+ { type: "pointer-move", x: 20, y: 12, targetCell: { col: 4, row: 1 } },
OPTS,
);
expect(transition.phase).toEqual({
@@ -34,7 +34,7 @@ describe("nextDragPhase — mouse", () => {
id: "t1",
x: 20,
y: 12,
- targetIndex: 3,
+ targetCell: { col: 4, row: 1 },
touch: false,
});
expect(transition.emit).toBeUndefined();
@@ -43,7 +43,7 @@ describe("nextDragPhase — mouse", () => {
it("movement within the threshold stays pressing", () => {
const transition = nextDragPhase(
press(),
- { type: "pointer-move", x: 12, y: 14, targetIndex: 0 },
+ { type: "pointer-move", x: 12, y: 14, targetCell: { col: 1, row: 1 } },
OPTS,
);
expect(transition.phase).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false });
@@ -58,16 +58,16 @@ describe("nextDragPhase — mouse", () => {
it("release while dragging emits a drop with the last tracked index", () => {
let transition: DragTransition = nextDragPhase(
press(),
- { type: "pointer-move", x: 30, y: 30, targetIndex: 2 },
+ { type: "pointer-move", x: 30, y: 30, targetCell: { col: 3, row: 1 } },
OPTS,
);
transition = nextDragPhase(
transition.phase,
- { type: "pointer-move", x: 31, y: 31, targetIndex: 5 },
+ { type: "pointer-move", x: 31, y: 31, targetCell: { col: 6, row: 1 } },
OPTS,
);
transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS);
- expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetIndex: 5 });
+ expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: { col: 6, row: 1 } });
expect(transition.phase).toEqual({ kind: "idle" });
});
});
@@ -81,7 +81,7 @@ describe("nextDragPhase — touch", () => {
id: "t1",
x: 10,
y: 10,
- targetIndex: -1,
+ targetCell: null,
touch: true,
});
});
@@ -89,7 +89,7 @@ describe("nextDragPhase — touch", () => {
it("movement past the threshold BEFORE the lift cancels (vertical scrolling wins)", () => {
const transition = nextDragPhase(
press(true),
- { type: "pointer-move", x: 10, y: 80, targetIndex: 0 },
+ { type: "pointer-move", x: 10, y: 80, targetCell: { col: 1, row: 1 } },
OPTS,
);
expect(transition.phase).toEqual({ kind: "idle" });
@@ -99,7 +99,7 @@ describe("nextDragPhase — touch", () => {
it("movement within the threshold before the lift keeps pressing", () => {
const transition = nextDragPhase(
press(true),
- { type: "pointer-move", x: 13, y: 12, targetIndex: 0 },
+ { type: "pointer-move", x: 13, y: 12, targetCell: { col: 1, row: 1 } },
OPTS,
);
expect(transition.phase?.kind).toBe("pressing");
@@ -109,7 +109,7 @@ describe("nextDragPhase — touch", () => {
let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS);
transition = nextDragPhase(
transition.phase,
- { type: "pointer-move", x: 10, y: 90, targetIndex: 4 },
+ { type: "pointer-move", x: 10, y: 90, targetCell: { col: 5, row: 1 } },
OPTS,
);
expect(transition.phase).toEqual({
@@ -117,7 +117,7 @@ describe("nextDragPhase — touch", () => {
id: "t1",
x: 10,
y: 90,
- targetIndex: 4,
+ targetCell: { col: 5, row: 1 },
touch: true,
});
});
@@ -131,7 +131,7 @@ describe("nextDragPhase — touch", () => {
it("release while lifted (no movement) drops at the lifted tile's own slot", () => {
let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS);
transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS);
- expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetIndex: -1 });
+ expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: null });
});
});
@@ -140,7 +140,11 @@ describe("nextDragPhase — cancellations", () => {
for (const phase of [
{ kind: "idle" as const },
press(),
- nextDragPhase(press(), { type: "pointer-move", x: 40, y: 40, targetIndex: 1 }, OPTS).phase,
+ nextDragPhase(
+ press(),
+ { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } },
+ OPTS,
+ ).phase,
]) {
const transition = nextDragPhase(phase, { type: "cancel" }, OPTS);
expect(transition.phase).toEqual({ kind: "idle" });
@@ -153,8 +157,11 @@ describe("nextDragPhase — cancellations", () => {
kind: "idle",
});
expect(
- nextDragPhase({ kind: "idle" }, { type: "pointer-move", x: 1, y: 1, targetIndex: 0 }, OPTS)
- .phase,
+ nextDragPhase(
+ { kind: "idle" },
+ { type: "pointer-move", x: 1, y: 1, targetCell: { col: 1, row: 1 } },
+ OPTS,
+ ).phase,
).toEqual({ kind: "idle" });
expect(nextDragPhase({ kind: "idle" }, { type: "long-press" }, OPTS).phase).toEqual({
kind: "idle",
@@ -164,7 +171,7 @@ describe("nextDragPhase — cancellations", () => {
it("a second pointer-down restarts the gesture with the new tile", () => {
const dragging = nextDragPhase(
press(),
- { type: "pointer-move", x: 40, y: 40, targetIndex: 1 },
+ { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } },
OPTS,
).phase;
const transition = nextDragPhase(dragging, down({ id: "t2" }), OPTS);
diff --git a/src/features/workspaces/logic/drag-controller.ts b/src/features/workspaces/logic/drag-controller.ts
index 03dcade..a1877aa 100644
--- a/src/features/workspaces/logic/drag-controller.ts
+++ b/src/features/workspaces/logic/drag-controller.ts
@@ -12,9 +12,11 @@
* so the two gestures never fight. Release while lifted = DROP.
*
* Pure: `(phase, event, options) → { phase, emit }`. The component owns the
- * reactive `$state` and performs the emits (navigate / select / reorder).
+ * reactive `$state` and performs the emits (navigate / select / place).
*/
+import type { Cell } from "./tile-layout";
+
/** Where the interaction currently stands. */
export type DragPhase =
| { readonly kind: "idle" }
@@ -32,7 +34,8 @@ export type DragPhase =
readonly id: string;
readonly x: number;
readonly y: number;
- readonly targetIndex: number;
+ /** The grid CELL under the pointer (null = not over the canvas yet). */
+ readonly targetCell: Cell | null;
readonly touch: boolean;
};
@@ -40,7 +43,7 @@ export type DragPhase =
export type DragEmit =
| { readonly kind: "tap"; readonly id: string }
| { readonly kind: "lift"; readonly id: string }
- | { readonly kind: "drop"; readonly id: string; readonly targetIndex: number };
+ | { readonly kind: "drop"; readonly id: string; readonly targetCell: Cell | null };
/** Events the component dispatches (DOM events + the long-press timer firing). */
export type DragEvent =
@@ -55,7 +58,7 @@ export type DragEvent =
readonly type: "pointer-move";
readonly x: number;
readonly y: number;
- readonly targetIndex: number;
+ readonly targetCell: Cell | null;
}
| { readonly type: "pointer-up" }
| { readonly type: "long-press" }
@@ -105,7 +108,7 @@ export function nextDragPhase(
id: phase.id,
x: event.x,
y: event.y,
- targetIndex: event.targetIndex,
+ targetCell: event.targetCell,
touch: phase.touch,
},
};
@@ -116,7 +119,7 @@ export function nextDragPhase(
...phase,
x: event.x,
y: event.y,
- targetIndex: event.targetIndex,
+ targetCell: event.targetCell,
},
};
}
@@ -130,7 +133,7 @@ export function nextDragPhase(
if (phase.kind === "dragging") {
return {
phase: IDLE,
- emit: { kind: "drop", id: phase.id, targetIndex: phase.targetIndex },
+ emit: { kind: "drop", id: phase.id, targetCell: phase.targetCell },
};
}
return { phase };
@@ -145,7 +148,7 @@ export function nextDragPhase(
id: phase.id,
x: phase.x,
y: phase.y,
- targetIndex: -1,
+ targetCell: null,
touch: true,
},
emit: { kind: "lift", id: phase.id },
diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts
index be61da4..81156e7 100644
--- a/src/features/workspaces/logic/tile-layout.test.ts
+++ b/src/features/workspaces/logic/tile-layout.test.ts
@@ -1,18 +1,23 @@
import { describe, expect, it } from "vitest";
import {
clampSpan,
+ displacePlace,
fillOrderKey,
firstEmptySlot,
type GridGeometry,
geometryFor,
- insertAtFirstEmptySlot,
mergeLayout,
- moveTile,
+ type Placement,
pack,
+ pinAtFirstEmpty,
+ placeAtCell,
resizeTile,
sanitizeLayout,
sortByRecentActivity,
type TileLayoutEntry,
+ toRenderCell,
+ toUniversalCell,
+ UNIVERSAL_ROWS,
} from "./tile-layout";
const WIDE = geometryFor(1920, 1080);
@@ -237,13 +242,13 @@ describe("firstEmptySlot", () => {
});
});
-describe("insertAtFirstEmptySlot", () => {
- it("inserts a new tile so it packs into the first empty slot", () => {
+describe("pinAtFirstEmpty", () => {
+ it("pins a new tile at the first empty slot (holes count)", () => {
// A double-height tile fills rows 1-2, a single fills row 3 → the first
- // empty slot is (col1,row4); the new tile must land exactly there.
+ // empty slot is (col1,row4); the new tile PINS exactly there.
const tiles = [tile("big", 0, 1, 2), tile("c", 1)];
- const next = insertAtFirstEmptySlot(tiles, "new", WIDE);
- expect(next.map((t) => t.id)).toEqual(["big", "c", "new"]);
+ const next = pinAtFirstEmpty(tiles, "new", WIDE);
+ expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 });
const placements = pack(next, WIDE);
expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([
["big", 1, 1],
@@ -252,31 +257,43 @@ describe("insertAtFirstEmptySlot", () => {
]);
});
- it("renumbers orders densely after the insert", () => {
- const next = insertAtFirstEmptySlot([tile("a", 0, 1, 2), tile("b", 7)], "new", WIDE);
- expect(next.map((t) => t.order)).toEqual([0, 1, 2]);
+ it("pins into a GAP between pinned tiles", () => {
+ // big is pinned out of the way; the flow left row 3 free for a 1×1.
+ const tiles = [
+ { ...tile("big", 0, 1, 2), col: 1, row: 1 },
+ { ...tile("z", 1), col: 1, row: 4 },
+ ];
+ const next = pinAtFirstEmpty(tiles, "new", WIDE);
+ expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 3 });
});
- it("moves an already-present tile instead of duplicating (refresh-race idempotency)", () => {
+ it("re-pins an already-present tile instead of duplicating (refresh-race idempotency)", () => {
const tiles = [tile("a", 0), tile("x", 1), tile("b", 2, 1, 2)];
- const first = insertAtFirstEmptySlot(tiles, "x", WIDE);
- const second = insertAtFirstEmptySlot(first, "x", WIDE);
+ const first = pinAtFirstEmpty(tiles, "x", WIDE);
+ const second = pinAtFirstEmpty(first, "x", WIDE);
expect(second).toEqual(first);
expect(second.filter((t) => t.id === "x")).toHaveLength(1);
});
- it("appends when the empty slot is beyond the current extent", () => {
+ it("appends (pins beyond the current extent) when the grid is full", () => {
const tiles = [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)];
- const next = insertAtFirstEmptySlot(tiles, "e", WIDE);
- expect(next.map((t) => t.id)).toEqual(["a", "b", "c", "d", "e"]);
- expect(pack(next, WIDE).at(-1)).toMatchObject({ id: "e", col: 2, row: 1 });
+ const next = pinAtFirstEmpty(tiles, "e", WIDE);
+ expect(next.find((t) => t.id === "e")).toMatchObject({ col: 2, row: 1 });
});
- it("preserves the spans of a moved existing tile", () => {
+ it("preserves the spans of a re-pinned existing tile", () => {
const tiles = [tile("x", 0, 2, 2), tile("a", 1), tile("b", 2), tile("c", 3)];
- const next = insertAtFirstEmptySlot(tiles, "x", WIDE);
+ const next = pinAtFirstEmpty(tiles, "x", WIDE);
expect(next.find((t) => t.id === "x")).toMatchObject({ colSpan: 2, rowSpan: 2 });
});
+
+ it("saves the pin in the universal frame (tall)", () => {
+ const tiles = [{ ...tile("a", 0), col: 1, row: 1 }];
+ const next = pinAtFirstEmpty(tiles, "new", TALL);
+ // a renders at tall (1,1); the first empty slot is tall (2,1) → universal
+ // (col 1, row 2).
+ expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 2 });
+ });
});
describe("resizeTile", () => {
@@ -304,31 +321,203 @@ describe("resizeTile", () => {
});
});
-describe("moveTile", () => {
- it("moves a tile forward, landing after the hovered tile", () => {
- const next = moveTile([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], "a", 2);
- expect(next.map((t) => t.id)).toEqual(["b", "c", "a", "d"]);
+describe("displacePlace", () => {
+ /** Render placements as the column-1 stream (cells 1..n, `_` = empty,
+ * one character per tile — the id's first char). */
+ const stream = (placements: Placement[], geometry: GridGeometry): string => {
+ const cells = new Map<number, string>();
+ for (const p of placements) {
+ for (let r = p.row; r < p.row + p.rowSpan; r++) {
+ for (let c = p.col; c < p.col + p.colSpan; c++) {
+ cells.set(fillOrderKey(c, r, geometry), p.id.charAt(0));
+ }
+ }
+ }
+ const maxKey = Math.max(0, ...cells.keys());
+ const out: string[] = [];
+ for (let key = 0; key <= maxKey; key++) {
+ out.push(cells.get(key) ?? "_");
+ }
+ return out.join("");
+ };
+
+ it("USER EXAMPLE 1: X_X + Y placed in slot 1 → YXX (the gap absorbs the shift)", () => {
+ // X at cells 1,3 (wide: (1,1) and (1,3)); Y sits at cell 5 (dragged in
+ // from outside the shown range — its origin is beyond the gap).
+ const placements: Placement[] = [
+ { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE);
+ // Y@1; X (was@1) pushed into the gap at 2; X2 (was@3) stays.
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["Y", 1, 1],
+ ["X", 1, 2],
+ ["X2", 1, 3],
+ ]);
+ });
+
+ it("USER EXAMPLE 2: _XZ_ + Y placed in slot 2 → _YXZ (leading gap preserved)", () => {
+ // X@(1,2), Z@(1,3); Y dragged in from cell 5.
+ const placements: Placement[] = [
+ { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "Y", { col: 1, row: 2 }, WIDE);
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["Y", 1, 2],
+ ["X", 1, 3],
+ ["Z", 1, 4],
+ ]);
+ });
+
+ it("dragging a tile OUT opens a gap at its old anchor (nothing slides back)", () => {
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ ];
+ // Drag a (cell 1) to cell 3 — b stays at 2; a lands at 3.
+ const next = displacePlace(placements, "a", { col: 1, row: 3 }, WIDE);
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["b", 1, 2],
+ ["a", 1, 3],
+ ]);
+ });
+
+ it("internal move rightward over a full run pushes the tail forward", () => {
+ // a,b,c in cells 1-3; drag a to cell 2: b→3 pushes c→4.
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "a", { col: 1, row: 2 }, WIDE);
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 1, 2],
+ ["b", 1, 3],
+ ["c", 1, 4],
+ ]);
+ });
+
+ it("an unknown dragged id changes nothing (a copy of the input)", () => {
+ const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }];
+ const next = displacePlace(placements, "nope", { col: 1, row: 2 }, WIDE);
+ expect(next).toEqual(placements);
+ });
+
+ it("a spanned displaced tile keeps its spans and re-anchors forward as a rect", () => {
+ // big (2-tall) at rows 1-2; tail (2-tall) at rows 3-4. Drag big to (1,2):
+ // its own old body is free-ish but (1,2)+(1,3) collides with tail's
+ // (1,3)+(1,4) → tail re-anchors FORWARD: no 2-tall slot fits in column 1
+ // anymore → column 2.
+ const placements: Placement[] = [
+ { id: "big", col: 1, row: 1, colSpan: 1, rowSpan: 2 },
+ { id: "tail", col: 1, row: 3, colSpan: 1, rowSpan: 2 },
+ ];
+ const next = displacePlace(placements, "big", { col: 1, row: 2 }, WIDE);
+ expect(next.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([
+ ["big", 1, 2, 2],
+ ["tail", 2, 1, 2],
+ ]);
});
- it("moves a tile backward, landing before the hovered tile", () => {
- const next = moveTile([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], "d", 1);
- expect(next.map((t) => t.id)).toEqual(["a", "d", "b", "c"]);
+ it("displacing into the NEXT COLUMN when the column is full", () => {
+ // 4 tiles fill column 1; drag the first to the LAST cell of column 1.
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "a", { col: 1, row: 4 }, WIDE);
+ // d is pushed past the column's end → column 2 row 1. Stream order:
+ // b(1) c(2) a(3) d(4).
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["b", 1, 2],
+ ["c", 1, 3],
+ ["a", 1, 4],
+ ["d", 2, 1],
+ ]);
});
- it("renumbers orders densely", () => {
- const next = moveTile([tile("a", 0), tile("b", 3), tile("c", 9)], "c", 0);
- expect(next.map((t) => t.order)).toEqual([0, 1, 2]);
+ it("works in TALL mode along the row-major stream", () => {
+ // Tall: 2 columns, row-major. a@(1,1), b@(2,1); drag a to cell (2,1).
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 2, row: 1, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "a", { col: 2, row: 1 }, TALL);
+ // b pushed forward past the row's end → next row. Stream order: a(1) b(2).
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 2, 1],
+ ["b", 1, 2],
+ ]);
});
- it("is a no-op for the same index or an unknown id", () => {
+ it("the stream helper renders the documented examples (sanity)", () => {
+ const placements: Placement[] = [
+ { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE);
+ expect(stream(next, WIDE)).toBe("YXX");
+ const placements2: Placement[] = [
+ { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 },
+ ];
+ const next2 = displacePlace(placements2, "Y", { col: 1, row: 2 }, WIDE);
+ expect(stream(next2, WIDE)).toBe("_YXZ");
+ });
+});
+
+describe("placeAtCell", () => {
+ it("pins every tile at its displaced anchor in the universal frame", () => {
+ // Wide: a@(1,1), b@(1,2); drag a to (2,1) — a pins there, b KEEPS its
+ // cell (the gap at (1,1) opens and stays).
const tiles = [tile("a", 0), tile("b", 1)];
- expect(moveTile(tiles, "a", 0)).toEqual(tiles);
- expect(moveTile(tiles, "x", 1)).toEqual(tiles);
+ const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, WIDE);
+ expect(next.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 });
+ expect(next.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 });
+ // And the pinned layout packs exactly as placed (b first — stream order).
+ expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([
+ ["b", 1, 2],
+ ["a", 2, 1],
+ ]);
});
- it("clamps out-of-range indices", () => {
- const next = moveTile([tile("a", 0), tile("b", 1)], "a", 99);
- expect(next.map((t) => t.id)).toEqual(["b", "a"]);
+ it("keeps persisted spans raw (never clamped) through the commit", () => {
+ // A 4-wide desktop tile dropped in TALL commits the tall cell but keeps
+ // its raw 4-wide span.
+ const tiles = [{ ...tile("big", 0, 4, 1) }, tile("b", 1)];
+ const next = placeAtCell(tiles, "big", { col: 1, row: 3 }, TALL);
+ expect(next.find((t) => t.id === "big")).toMatchObject({
+ colSpan: 4,
+ rowSpan: 1,
+ col: 1,
+ row: 5,
+ });
+ });
+
+ it("renumbers orders densely in stream order", () => {
+ const tiles = [tile("a", 0), tile("b", 5), tile("c", 9)];
+ const next = placeAtCell(tiles, "c", { col: 1, row: 1 }, WIDE);
+ expect(next.every((t, i) => t.order === i)).toBe(true);
+ });
+
+ it("maps tall commits to the universal frame", () => {
+ // Tall (col 2, row 1) → universal (col 1, row 2).
+ const tiles = [tile("a", 0)];
+ const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, TALL);
+ expect(next.find((t) => t.id === "a")).toMatchObject({ col: 1, row: 2 });
+ });
+
+ it("an unknown id changes nothing (no-op)", () => {
+ const tiles = [tile("a", 0)];
+ expect(placeAtCell(tiles, "nope", { col: 1, row: 1 }, WIDE)).toEqual(tiles);
});
});
@@ -355,6 +544,26 @@ describe("sortByRecentActivity", () => {
["b", 1],
]);
});
+
+ it("CLEARs every pin (an explicit arrangement reset — gaps gone)", () => {
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("a", 0), col: 1, row: 1 },
+ { ...tile("b", 1), col: 2, row: 3 },
+ ];
+ const next = sortByRecentActivity(
+ tiles,
+ new Map([
+ ["a", 100],
+ ["b", 300],
+ ]),
+ );
+ expect(next.every((t) => t.col === undefined && t.row === undefined)).toBe(true);
+ // And the now-unpinned layout packs compactly.
+ expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([
+ ["b", 1, 1],
+ ["a", 1, 2],
+ ]);
+ });
});
describe("mergeLayout", () => {
@@ -411,20 +620,32 @@ describe("mergeLayout", () => {
});
describe("sanitizeLayout", () => {
- it("accepts a valid document", () => {
+ it("accepts a valid v2 document (anchors kept)", () => {
+ expect(
+ sanitizeLayout({
+ version: 2,
+ tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }],
+ }),
+ ).toEqual({
+ version: 2,
+ tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }],
+ });
+ });
+
+ it("MIGRATES a v1 document to v2 in memory (all tiles unpinned)", () => {
expect(
sanitizeLayout({ version: 1, tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }] }),
).toEqual({
- version: 1,
+ version: 2,
tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }],
});
});
it("rejects foreign versions and malformed documents", () => {
- expect(sanitizeLayout(null)).toEqual({ version: 1, tiles: [] });
- expect(sanitizeLayout("nope")).toEqual({ version: 1, tiles: [] });
- expect(sanitizeLayout({ version: 2, tiles: [] })).toEqual({ version: 1, tiles: [] });
- expect(sanitizeLayout({ version: 1, tiles: "x" })).toEqual({ version: 1, tiles: [] });
+ expect(sanitizeLayout(null)).toEqual({ version: 2, tiles: [] });
+ expect(sanitizeLayout("nope")).toEqual({ version: 2, tiles: [] });
+ expect(sanitizeLayout({ version: 3, tiles: [] })).toEqual({ version: 2, tiles: [] });
+ expect(sanitizeLayout({ version: 1, tiles: "x" })).toEqual({ version: 2, tiles: [] });
});
it("coerces malformed entries and drops anonymous/duplicate ones", () => {
@@ -443,10 +664,30 @@ describe("sanitizeLayout", () => {
{ id: "b", colSpan: 3, rowSpan: 1, order: 2 },
]);
});
+
+ it("drops garbage anchors (non-positive / fractional / half a pair)", () => {
+ const data = sanitizeLayout({
+ version: 2,
+ tiles: [
+ { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 },
+ { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2.5, row: 3 },
+ { id: "zero", colSpan: 1, rowSpan: 1, order: 2, col: 0, row: 1 },
+ { id: "lone", colSpan: 1, rowSpan: 1, order: 3, col: 2 },
+ { id: "neg", colSpan: 1, rowSpan: 1, order: 4, col: -1, row: -1 },
+ ],
+ });
+ expect(data.tiles).toEqual([
+ { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 },
+ { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2, row: 3 },
+ { id: "zero", colSpan: 1, rowSpan: 1, order: 2 },
+ { id: "lone", colSpan: 1, rowSpan: 1, order: 3 },
+ { id: "neg", colSpan: 1, rowSpan: 1, order: 4 },
+ ]);
+ });
});
describe("geometry-agnostic persistence", () => {
- it("the SAME persisted data renders in either geometry (positions re-derived, never stored)", () => {
+ it("the SAME persisted data renders in either geometry (unpinned flow re-derives)", () => {
const tiles: TileLayoutEntry[] = [
tile("a", 0, 4, 2),
tile("b", 1),
@@ -460,11 +701,112 @@ describe("geometry-agnostic persistence", () => {
expect(widePlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 4, rowSpan: 2 });
expect(widePlacements.at(-1)).toMatchObject({ id: "e" });
expect(tallPlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 2, rowSpan: 2 });
- // The document carries NO orientation/position field — only id/spans/order.
+ // An unpinned entry carries NO anchor — only id/spans/order.
expect(Object.keys(tiles[0] ?? {}).sort()).toEqual(["colSpan", "id", "order", "rowSpan"]);
});
});
+describe("pinned pack (v2)", () => {
+ it("pinned tiles land at their anchors; unpinned flow into the remaining free cells", () => {
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("a", 0), col: 1, row: 3 },
+ { ...tile("b", 1), col: 1, row: 1 },
+ tile("c", 2), // unpinned → first free = (1,2)
+ tile("d", 3), // unpinned → next free = (1,4)
+ ];
+ // pack returns ENTRY order (a, b, c, d); positions are what matters.
+ const placements = pack(tiles, WIDE);
+ expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 1, 3],
+ ["b", 1, 1],
+ ["c", 1, 2],
+ ["d", 1, 4],
+ ]);
+ });
+
+ it("keeps the GAP between two pins (flow does not compact it away)", () => {
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("a", 0), col: 1, row: 1 },
+ { ...tile("z", 1), col: 1, row: 4 },
+ ];
+ expect(pack(tiles, WIDE).map((p) => [p.id, p.row])).toEqual([
+ ["a", 1],
+ ["z", 4],
+ ]);
+ });
+
+ it("a pin whose span no longer fits its anchor reads as UNPINNED (flows)", () => {
+ // A 2-tall tile pinned at row 4 cannot fit the 4-row cross-axis at that
+ // anchor → it flows to the first fitting slot instead: (1,2).
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("big", 0, 1, 2), col: 1, row: 4 },
+ { ...tile("a", 1), col: 1, row: 1 },
+ ];
+ const placements = pack(tiles, WIDE);
+ expect(placements.find((p) => p.id === "big")).toMatchObject({ col: 1, row: 2 });
+ });
+
+ it("overlapping pins resolve first-wins by entry order (the loser flows)", () => {
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("a", 0), col: 1, row: 2 },
+ { ...tile("b", 1), col: 1, row: 2 },
+ ];
+ const placements = pack(tiles, WIDE);
+ expect(placements.find((p) => p.id === "a")).toMatchObject({ col: 1, row: 2 });
+ expect(placements.find((p) => p.id === "b")).toMatchObject({ col: 1, row: 1 });
+ });
+
+ it("a wide pin at col > 1 reads as UNPINNED in tall (documented lossy case)", () => {
+ const tiles: TileLayoutEntry[] = [{ ...tile("a", 0), col: 3, row: 1 }];
+ expect(pack(tiles, TALL).find((p) => p.id === "a")).toMatchObject({ col: 1, row: 1 });
+ });
+
+ it("tall pins map through the universal frame in both directions", () => {
+ // Universal column 1 folds onto tall's 2 columns in reading order:
+ // u1→(1,1) u2→(2,1) u3→(1,2) u4→(2,2).
+ expect(toRenderCell({ col: 1, row: 1 }, TALL)).toEqual({ col: 1, row: 1 });
+ expect(toRenderCell({ col: 1, row: 2 }, TALL)).toEqual({ col: 2, row: 1 });
+ expect(toRenderCell({ col: 1, row: 3 }, TALL)).toEqual({ col: 1, row: 2 });
+ expect(toRenderCell({ col: 1, row: 4 }, TALL)).toEqual({ col: 2, row: 2 });
+ expect(toUniversalCell({ col: 2, row: 2 }, TALL)).toEqual({ col: 1, row: 4 });
+ // Wide is the identity.
+ expect(toRenderCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 });
+ expect(toUniversalCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 });
+ expect(UNIVERSAL_ROWS).toBe(4);
+ });
+
+ it("a layout dragged in TALL renders its pins when back in WIDE", () => {
+ // Committed in tall: a at tall (1,1) → universal (1,1); b at tall (2,1) →
+ // universal (1,2). Back in wide they render exactly there (col 1).
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("a", 0), col: 1, row: 1 },
+ { ...tile("b", 1), col: 1, row: 2 },
+ ];
+ expect(pack(tiles, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 1, 1],
+ ["b", 1, 2],
+ ]);
+ });
+
+ it("deleting a workspace leaves its cells EMPTY (mergeLayout keeps other pins)", () => {
+ const persisted = {
+ version: 2 as const,
+ tiles: [
+ { ...tile("a", 0), col: 1, row: 1 },
+ { ...tile("gone", 1), col: 1, row: 2 },
+ { ...tile("b", 2), col: 1, row: 3 },
+ ] as TileLayoutEntry[],
+ };
+ const merged = mergeLayout(persisted, ["a", "b"]);
+ expect(merged.map((t) => [t.id, t.col, t.row])).toEqual([
+ ["a", 1, 1],
+ ["b", 1, 3],
+ ]);
+ // The gap at (1,2) stays: a new workspace flows/pins INTO it.
+ expect(firstEmptySlot(merged, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 2 });
+ });
+});
+
describe("type surface", () => {
it("exposes the geometry fields the picker and grid need", () => {
// A 500px-tall canvas: the raw unit (100) clamps UP to the 112px minimum,
diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts
index 4d81a0c..4b5c89c 100644
--- a/src/features/workspaces/logic/tile-layout.ts
+++ b/src/features/workspaces/logic/tile-layout.ts
@@ -2,13 +2,16 @@
* Pure tile-layout logic for the Win8-Start-style workspace home grid — zero
* DOM, zero Svelte, zero effects.
*
- * The PERSISTED data is orientation-independent: `{ version, tiles: [{ id,
- * colSpan, rowSpan, order }] }` — the manual tile order + requested spans
- * ONLY, never positions. Positions are re-derived by {@link pack} from the
- * CURRENT geometry, so the same data renders in either geometry (wide desktop
- * / tall mobile) with no orientation conversion step: a tile whose persisted
- * span exceeds the geometry's cap simply renders clamped (the persisted span
- * is untouched).
+ * The PERSISTED data (v2): `{ version, tiles: [{ id, colSpan, rowSpan, order,
+ * col?, row? }] }` — each tile's spans, arrival order, and OPTIONAL pinned
+ * anchor. Anchors live in a UNIVERSAL 4-row frame (wide's frame; tall folds
+ * its 2 columns onto frame column 1), so the same document renders in either
+ * geometry with no orientation conversion step: an anchor that doesn't map
+ * into the current geometry (or whose clamped span can't fit there) reads as
+ * UNPINNED, and unpinned tiles flow into the first free cells — holes between
+ * pins included. Empty cells are real: nothing ever slides backward, so gaps
+ * only close when a drop's displacement pushes a run of filled cells through
+ * them (see {@link displacePlace}).
*
* Terminology (distinct from the layout feature's nested split-tree "tile"):
* a HOME TILE is one workspace's cell in the flat start-screen grid.
@@ -55,19 +58,28 @@ export interface GridGeometry {
readonly margin: number;
}
-/** One persisted tile: which workspace it pins, how big it wants to be, and its manual position in the order. */
+/** One persisted tile: which workspace it pins, how big it wants to be, its manual order, and its anchor. */
export interface TileLayoutEntry {
/** The workspace id (the slug). */
readonly id: string;
readonly colSpan: number;
readonly rowSpan: number;
- /** Lower sorts earlier. Positions are NEVER persisted — only this order. */
+ /** Lower sorts earlier. Arrival order for unpinned flow + first-wins tie-breaks. */
readonly order: number;
+ /**
+ * The pinned anchor cell (1-based) in the UNIVERSAL 4-row frame, or absent =
+ * unpinned (the tile flows into the first free cells at render time). Tall
+ * mode normalizes its 2-column grid into this frame: render { c, r } ↔
+ * universal { col: 1, row: 2(r−1)+c }. A pin outside the current geometry's
+ * fixed cross-axis reads as UNPINNED (render-time clamp — like spans).
+ */
+ readonly col?: number;
+ readonly row?: number;
}
/** The persisted document (localStorage via the injected `LocalStore` adapter). */
export interface TileLayoutData {
- readonly version: 1;
+ readonly version: 1 | 2;
readonly tiles: readonly TileLayoutEntry[];
}
@@ -187,6 +199,37 @@ export function fillOrderKey(col: number, row: number, geometry: GridGeometry):
return (row - 1) * geometry.flowCols + (col - 1);
}
+/**
+ * The universal frame's fixed row count (the wide grid's). Tall render cells
+ * map onto column 1 of this frame; wide is the identity.
+ */
+export const UNIVERSAL_ROWS = 4;
+
+/** A tile's anchor cell (1-based). */
+export interface Cell {
+ readonly col: number;
+ readonly row: number;
+}
+
+/**
+ * Universal-frame anchor → render-geometry cell. Wide: identity. Tall: column
+ * 1 of the universal frame folds onto the 2 render columns (universal row u →
+ * render { col: (u−1)%2 + 1, row: ⌈u/2⌉ }); any universal col > 1 cannot be
+ * represented in tall → `null` (the tile reads as UNPINNED in tall).
+ * Pure.
+ */
+export function toRenderCell(anchor: Cell, geometry: GridGeometry): Cell | null {
+ if (geometry.mode === "wide") return anchor;
+ if (anchor.col !== 1) return null;
+ return { col: ((anchor.row - 1) % 2) + 1, row: Math.floor((anchor.row - 1) / 2) + 1 };
+}
+
+/** Render-geometry cell → universal-frame anchor (the commit direction). Pure. */
+export function toUniversalCell(cell: Cell, geometry: GridGeometry): Cell {
+ if (geometry.mode === "wide") return cell;
+ return { col: 1, row: 2 * (cell.row - 1) + cell.col };
+}
+
function cellsFree(
occupied: ReadonlySet<string>,
col: number,
@@ -260,24 +303,180 @@ function firstFit(
}
/**
- * THE order → coordinates transform: place the tiles (in their persisted
- * order) into the first fitting slot in the geometry's fill order — greedy
- * first-fit, so every fitting slot is used before the flow advances (no
- * intentional holes). Requested spans clamp to the geometry (a 3×1 desktop
- * tile in tall mode packs as 2×1 — the persisted span is untouched). Pure:
- * entries + geometry in, a NEW placements array out (in pack order, which
- * respects the persisted order).
+ * THE entries → coordinates transform, v2. Two passes, one scan each:
+ *
+ * 1. **Pinned pass** (in entry order — first-pinned-wins on overlap, strictly
+ * one-ply: a displaced pin never bumps another pin): a pinned tile lands at
+ * its anchor (universal frame → render cell) when the anchor maps into this
+ * geometry, its clamped footprint fits the fixed cross-axis there, and no
+ * already-placed tile overlaps. Otherwise the tile reads as UNPINNED.
+ * 2. **Flow pass**: unpinned tiles (never pinned, out-of-range, clashing) take
+ * the first fitting FREE slot in fill order — holes between pins count.
+ *
+ * Requested spans clamp to the geometry (render-time only; the persisted spans
+ * stay raw). Pure: entries + geometry in, a NEW placements array out (entry
+ * order — the render order).
*/
export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] {
const occupied = new Set<string>();
- const placements: Placement[] = [];
- for (const tile of sortTiles(tiles)) {
+ const placements: (Placement | undefined)[] = [];
+ const flowing: { index: number; tile: TileLayoutEntry }[] = [];
+ const ordered = sortTiles(tiles);
+ for (let index = 0; index < ordered.length; index++) {
+ const tile = ordered[index];
+ if (!tile) continue;
+ const { colSpan, rowSpan } = clampSpan(tile, geometry);
+ const anchor = pinnedAnchor(tile, geometry);
+ if (
+ anchor !== null &&
+ fitsCrossAxis(anchor, colSpan, rowSpan, geometry) &&
+ cellsFree(occupied, anchor.col, anchor.row, colSpan, rowSpan)
+ ) {
+ occupy(occupied, anchor.col, anchor.row, colSpan, rowSpan);
+ placements[index] = { id: tile.id, col: anchor.col, row: anchor.row, colSpan, rowSpan };
+ } else {
+ flowing.push({ index, tile });
+ }
+ }
+ for (const { index, tile } of flowing) {
const { colSpan, rowSpan } = clampSpan(tile, geometry);
const slot = firstFit(occupied, { colSpan, rowSpan }, geometry);
occupy(occupied, slot.col, slot.row, colSpan, rowSpan);
- placements.push({ id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan });
+ placements[index] = { id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan };
}
- return placements;
+ return placements.filter((placement) => placement !== undefined);
+}
+
+/**
+ * A pinned tile's render-geometry anchor, or `null` when the tile is unpinned
+ * (no anchor) or its anchor cannot be represented in this geometry (a wide
+ * column > 1 read in tall). Pure.
+ */
+function pinnedAnchor(tile: TileLayoutEntry, geometry: GridGeometry): Cell | null {
+ if (tile.col === undefined || tile.row === undefined) return null;
+ return toRenderCell({ col: tile.col, row: tile.row }, geometry);
+}
+
+/** The footprint fits the geometry's FIXED cross-axis at this anchor. */
+function fitsCrossAxis(
+ anchor: Cell,
+ colSpan: number,
+ rowSpan: number,
+ geometry: GridGeometry,
+): boolean {
+ if (geometry.mode === "wide") return anchor.row + rowSpan - 1 <= geometry.flowRows;
+ return anchor.col + colSpan - 1 <= geometry.flowCols;
+}
+
+/**
+ * The first fitting slot at or after a minimum stream key (the displacement
+ * re-anchor scan — pushed tiles only move FORWARD). Pure.
+ */
+function firstFitFrom(
+ occupied: ReadonlySet<string>,
+ span: Span,
+ geometry: GridGeometry,
+ minKey: number,
+): Cell {
+ const { colSpan, rowSpan } = clampSpan(span, geometry);
+ if (geometry.mode === "wide") {
+ const maxCol = maxOccupied(occupied, "col") + 1;
+ for (let col = 1; col <= maxCol + 1; col++) {
+ for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) {
+ if (fillOrderKey(col, row, geometry) < minKey) continue;
+ if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
+ }
+ }
+ return { col: maxCol + 2, row: 1 };
+ }
+ const maxRow = maxOccupied(occupied, "row") + 1;
+ for (let row = 1; row <= maxRow + 1; row++) {
+ for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) {
+ if (fillOrderKey(col, row, geometry) < minKey) continue;
+ if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
+ }
+ }
+ return { col: 1, row: maxRow + 2 };
+}
+
+/**
+ * THE displacement op — drop `draggedId` at `cell` over the CURRENT RENDER
+ * placements (the squares exactly as the user sees them). Semantics (user-
+ * specified): the dragged tile leaves its slot (the gap OPENS — nothing slides
+ * backward, ever), anchors exactly at `cell`, and the contiguous run of filled
+ * cells pushes FORWARD in fill order until the first empty cell absorbs it:
+ *
+ * `X _ X` + Y at slot 1 → `Y X X`
+ * `_ X Z _` + Y at slot 2 → `_ Y X Z`
+ *
+ * Tiles keep their positions unless their footprint overlaps an already-placed
+ * one; the displaced re-anchor at the next fitting slot forward (spans ride
+ * along; a span that can't fit the fixed cross-axis anywhere forward wraps to
+ * the first fitting slot — it must land somewhere). Pure: placements + drop
+ * in, a NEW placements array out (stream order).
+ */
+export function displacePlace(
+ placements: readonly Placement[],
+ draggedId: string,
+ cell: Cell,
+ geometry: GridGeometry,
+): Placement[] {
+ const dragged = placements.find((placement) => placement.id === draggedId);
+ if (!dragged) return [...placements];
+ const occupied = new Set<string>();
+ occupy(occupied, cell.col, cell.row, dragged.colSpan, dragged.rowSpan);
+ const placed: Placement[] = [{ ...dragged, col: cell.col, row: cell.row }];
+ const others = placements
+ .filter((placement) => placement.id !== draggedId)
+ .map((placement) => ({
+ placement,
+ key: fillOrderKey(placement.col, placement.row, geometry),
+ }))
+ .sort((a, b) => a.key - b.key);
+ for (const { placement, key } of others) {
+ const { colSpan, rowSpan } = placement;
+ if (cellsFree(occupied, placement.col, placement.row, colSpan, rowSpan)) {
+ occupy(occupied, placement.col, placement.row, colSpan, rowSpan);
+ placed.push(placement);
+ continue;
+ }
+ let slot = firstFitFrom(occupied, placement, geometry, key);
+ if (!fitsCrossAxis(slot, colSpan, rowSpan, geometry)) {
+ slot = firstFit(occupied, placement, geometry);
+ }
+ occupy(occupied, slot.col, slot.row, colSpan, rowSpan);
+ placed.push({ ...placement, col: slot.col, row: slot.row });
+ }
+ return placed.sort(
+ (a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, geometry),
+ );
+}
+
+/**
+ * The commit path: pack the persisted entries, displace at `cell`, and pin
+ * EVERY tile back to the entries at its resulting anchor (universal frame —
+ * the persisted spans stay raw, only anchors + order change; order renumbers
+ * in stream order). This is the drag-drop write. Pure: entries in, NEW
+ * entries out.
+ */
+export function placeAtCell(
+ tiles: readonly TileLayoutEntry[],
+ id: string,
+ cell: Cell,
+ geometry: GridGeometry,
+): TileLayoutEntry[] {
+ // An unknown dragged id changes nothing (order-sorted copy — the op's
+ // canonical no-op shape, like `moveTile` before it).
+ if (!tiles.some((tile) => tile.id === id)) return sortTiles(tiles);
+ const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry);
+ const result: TileLayoutEntry[] = [];
+ for (const placement of displaced) {
+ const entry = tiles.find((tile) => tile.id === placement.id);
+ if (!entry) continue;
+ const anchor = toUniversalCell(placement, geometry);
+ result.push({ ...entry, col: anchor.col, row: anchor.row });
+ }
+ return result.map((entry, index) => (entry.order === index ? entry : { ...entry, order: index }));
}
/**
@@ -309,28 +508,33 @@ function renumber(tiles: readonly TileLayoutEntry[]): TileLayoutEntry[] {
}
/**
- * Insert (or MOVE an already-present tile to) the first empty slot in fill
- * order — the header-create placement. The new tile is 1×1, and inserting it
- * before the first placement that starts AFTER the empty slot makes greedy
- * packing drop it exactly into that slot. Idempotent: an id already in the
- * layout is moved (spans preserved), not duplicated — this closes the race
- * where the list-refresh sync appended the id first. Pure: entries in, a NEW
- * entries array out (renumbered).
+ * Pin a tile at the first EMPTY slot in fill order — the header-create
+ * placement (holes between pins count). Idempotent: an id already in the
+ * layout is RE-PINNED there (spans preserved), not duplicated — this closes
+ * the race where the list-refresh sync appended the id first. Unknown ids
+ * append as a new 1×1 pinned entry. Pure: entries in, a NEW entries array out
+ * (order preserved — anchors, not order, place tiles now).
*/
-export function insertAtFirstEmptySlot(
+export function pinAtFirstEmpty(
tiles: readonly TileLayoutEntry[],
id: string,
geometry: GridGeometry,
): TileLayoutEntry[] {
const without = sortTiles(tiles).filter((tile) => tile.id !== id);
const slot = firstEmptySlot(without, { colSpan: 1, rowSpan: 1 }, geometry);
- const slotKey = fillOrderKey(slot.col, slot.row, geometry);
- const placements = pack(without, geometry);
- let index = placements.findIndex((p) => fillOrderKey(p.col, p.row, geometry) > slotKey);
- if (index === -1) index = without.length;
+ const anchor = toUniversalCell(slot, geometry);
const existing = tiles.find((tile) => tile.id === id);
- const entry: TileLayoutEntry = existing ?? { id, colSpan: 1, rowSpan: 1, order: 0 };
- return renumber([...without.slice(0, index), entry, ...without.slice(index)]);
+ const entry: TileLayoutEntry = existing
+ ? { ...existing, col: anchor.col, row: anchor.row }
+ : {
+ id,
+ colSpan: 1,
+ rowSpan: 1,
+ order: without.reduce((max, tile) => Math.max(max, tile.order + 1), 0),
+ col: anchor.col,
+ row: anchor.row,
+ };
+ return [...without, entry];
}
/**
@@ -355,31 +559,10 @@ export function resizeTile(
}
/**
- * Move one tile to a position in the manual ORDER (the drag-reorder op). The
- * target index is in the CURRENT order-sorted array; the tile is removed and
- * re-inserted at the index, so dragging forward lands after the hovered tile
- * and dragging backward lands before it (the dragged tile takes the hovered
- * slot). Positions re-derive from the new order — never stored. Pure.
- */
-export function moveTile(
- tiles: readonly TileLayoutEntry[],
- id: string,
- toIndex: number,
-): TileLayoutEntry[] {
- const ordered = sortTiles(tiles);
- const from = ordered.findIndex((tile) => tile.id === id);
- if (from === -1 || toIndex === from) return ordered;
- const without = ordered.filter((tile) => tile.id !== id);
- const index = Math.max(0, Math.min(Math.floor(toIndex), without.length));
- const moved = ordered[from];
- if (!moved) return ordered;
- return renumber([...without.slice(0, index), moved, ...without.slice(index)]);
-}
-
-/**
- * Re-apply the recent-activity order (the header overflow "Sort" action):
- * tiles sort by their workspace's `lastActivityAt` descending (unknown ids
- * last, ties stable), then orders renumber. Pure.
+ * Re-apply the recent-activity order (the header overflow "Sort" action) AND
+ * clear every pin — an explicit ARRANGEMENT RESET: tiles sort by their
+ * workspace's `lastActivityAt` descending (unknown ids last, ties stable),
+ * orders renumber, and the now-unpinned tiles first-fit with no gaps. Pure.
*/
export function sortByRecentActivity(
tiles: readonly TileLayoutEntry[],
@@ -388,7 +571,12 @@ export function sortByRecentActivity(
const sorted = sortTiles(tiles).sort(
(a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0),
);
- return renumber(sorted);
+ return renumber(
+ sorted.map((tile) => {
+ const { col: _col, row: _row, ...unpinned } = tile;
+ return unpinned;
+ }),
+ );
}
/**
@@ -429,14 +617,17 @@ export function mergeLayout(
/**
* Coerce an unknown parsed-JSON value into layout data (the `LocalStore` load
- * is untyped at the boundary). Anything but `{ version: 1, tiles: [...] }`
- * yields the empty layout; malformed entries coerce (missing spans → 1,
- * garbage → dropped). Pure: unknown in, data out.
+ * is untyped at the boundary). v1 documents (order + spans only) MIGRATE to
+ * v2 in memory: their tiles become unpinned, which packs identically to v1's
+ * first-fit — the layout only starts carrying anchors after the user's first
+ * drop. v2 entries keep their anchors; malformed entries coerce (missing
+ * spans → 1, garbage anchors → unpinned, garbage entries → dropped). Anything
+ * else yields the empty layout. Pure: unknown in, v2 data out.
*/
export function sanitizeLayout(raw: unknown): TileLayoutData {
if (typeof raw !== "object" || raw === null) return EMPTY_LAYOUT;
const version = (raw as { version?: unknown }).version;
- if (version !== 1) return EMPTY_LAYOUT;
+ if (version !== 1 && version !== 2) return EMPTY_LAYOUT;
const rawTiles = (raw as { tiles?: unknown }).tiles;
if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT;
const tiles: TileLayoutEntry[] = [];
@@ -447,17 +638,21 @@ export function sanitizeLayout(raw: unknown): TileLayoutData {
const id = (rawTile as { id?: unknown }).id;
if (typeof id !== "string" || id === "" || seen.has(id)) continue;
seen.add(id);
+ const col = coerceAnchor((rawTile as { col?: unknown }).col);
+ const row = coerceAnchor((rawTile as { row?: unknown }).row);
tiles.push({
id,
colSpan: coerceSpan((rawTile as { colSpan?: unknown }).colSpan),
rowSpan: coerceSpan((rawTile as { rowSpan?: unknown }).rowSpan),
order: coerceOrder((rawTile as { order?: unknown }).order, index),
+ // An anchor is only meaningful as a PAIR — a lone coordinate drops both.
+ ...(version === 2 && col !== undefined && row !== undefined ? { col, row } : {}),
});
}
- return { version: 1, tiles };
+ return { version: 2, tiles };
}
-const EMPTY_LAYOUT: TileLayoutData = { version: 1, tiles: [] };
+const EMPTY_LAYOUT: TileLayoutData = { version: 2, tiles: [] };
function coerceSpan(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1;
@@ -468,3 +663,9 @@ function coerceOrder(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.floor(value);
}
+
+/** A valid anchor coordinate (1-based positive integer) or undefined. */
+function coerceAnchor(value: unknown): number | undefined {
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return undefined;
+ return Math.floor(value);
+}
diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts
index ce01748..2ca66a4 100644
--- a/src/features/workspaces/tile-layout-store.svelte.ts
+++ b/src/features/workspaces/tile-layout-store.svelte.ts
@@ -1,9 +1,10 @@
import type { LocalStore } from "../../adapters/local-storage";
import {
+ type Cell,
type GridGeometry,
- insertAtFirstEmptySlot,
mergeLayout,
- moveTile,
+ pinAtFirstEmpty,
+ placeAtCell,
resizeTile,
sanitizeLayout,
sortByRecentActivity,
@@ -12,41 +13,43 @@ import {
} from "./logic/tile-layout";
/**
- * The localStorage key for the home tile layout (order + spans only — never
- * positions; positions re-derive from the geometry on every render).
+ * The localStorage key for the home tile layout (spans + optional pinned
+ * anchors in the universal 4-row frame — v2 documents; v1 loads migrate).
*/
export const TILE_LAYOUT_STORAGE_KEY = "dispatch.workspaces.tiles";
/**
- * Tile-layout store — a thin reactive wrapper over the persisted
- * orientation-independent layout document, persisted through the INJECTED
- * `LocalStore` adapter (the repo's local-storage effect). All mutations go
- * through the pure `logic/tile-layout` ops and write through to persistence.
- * No ambient state: one instance per home page, created by the page entry
- * (or injected in tests).
+ * Tile-layout store — a thin reactive wrapper over the persisted tile layout
+ * document, persisted through the INJECTED `LocalStore` adapter (the repo's
+ * local-storage effect). All mutations go through the pure `logic/tile-layout`
+ * ops and write through to persistence. No ambient state: one instance per
+ * home page, created by the page entry (or injected in tests).
*/
export interface TileLayoutStore {
- /** The persisted entries, order-sorted (raw spans — NOT geometry-clamped). */
+ /** The persisted entries, order-sorted (raw spans + universal-frame anchors). */
readonly entries: readonly TileLayoutEntry[];
/**
* Merge the live workspace ids into the layout: workspaces unknown to the
- * layout append at the end, entries for deleted workspaces drop. A no-op
- * (no write) when the merge changes nothing — so an effect that tracks both
- * the workspace list and this store settles instead of looping.
+ * layout append (unpinned — they flow to the first free cells), entries for
+ * deleted workspaces drop (their cells become gaps). A no-op (no write) when
+ * the merge changes nothing — so an effect that tracks both the workspace
+ * list and this store settles instead of looping.
*/
sync(workspaceIds: readonly string[]): void;
/** Resize a tile (persisted spans; render-time clamping is separate). */
resize(id: string, colSpan: number, rowSpan: number): void;
- /** Move a tile to a position in the manual order (drag reorder). */
- move(id: string, toIndex: number): void;
/**
- * Place (or move an already-present) tile into the FIRST EMPTY SLOT in the
- * grid's fill order — the header-create flow. Idempotent (see
- * `insertAtFirstEmptySlot`), closing the refresh race where `sync` appended
- * the id first.
+ * Drop a tile at a specific CELL (the drag commit): the contiguous filled
+ * run pushes forward from that cell (gaps absorb it), every resulting
+ * position pins. See `placeAtCell`.
+ */
+ placeAtCell(id: string, cell: Cell, geometry: GridGeometry): void;
+ /**
+ * Pin (or re-pin) the tile at the FIRST EMPTY slot — the header-create
+ * flow. Idempotent (see `pinAtFirstEmpty`).
*/
placeFirstEmpty(id: string, geometry: GridGeometry): void;
- /** Re-apply the recent-activity order (the header overflow "Sort" action). */
+ /** Re-apply the recent-activity order AND clear all pins (the reset). */
sortByRecentActivity(lastActivityById: ReadonlyMap<string, number>): void;
}
@@ -55,7 +58,9 @@ export function createTileLayoutStore(persistence: LocalStore<TileLayoutData>):
let data = $state<TileLayoutData>(sanitizeLayout(persistence.load()));
function commit(tiles: readonly TileLayoutEntry[]): void {
- data = { version: 1, tiles };
+ // A pure op that changed nothing (e.g. a no-op place) doesn't write.
+ if (sameEntries(tiles, data.tiles)) return;
+ data = { version: 2, tiles };
persistence.save(data);
}
@@ -73,12 +78,12 @@ export function createTileLayoutStore(persistence: LocalStore<TileLayoutData>):
commit(resizeTile(data.tiles, id, colSpan, rowSpan));
},
- move(id, toIndex): void {
- commit(moveTile(data.tiles, id, toIndex));
+ placeAtCell(id, cell, geometry): void {
+ commit(placeAtCell(data.tiles, id, cell, geometry));
},
placeFirstEmpty(id, geometry): void {
- commit(insertAtFirstEmptySlot(data.tiles, id, geometry));
+ commit(pinAtFirstEmpty(data.tiles, id, geometry));
},
sortByRecentActivity(lastActivityById): void {
@@ -87,7 +92,7 @@ export function createTileLayoutStore(persistence: LocalStore<TileLayoutData>):
};
}
-/** Structural equality over entries (ids, spans, orders). */
+/** Structural equality over entries (ids, spans, orders, anchors). */
function sameEntries(a: readonly TileLayoutEntry[], b: readonly TileLayoutEntry[]): boolean {
if (a.length !== b.length) return false;
return a.every((entry, index) => {
@@ -97,7 +102,9 @@ function sameEntries(a: readonly TileLayoutEntry[], b: readonly TileLayoutEntry[
entry.id === other.id &&
entry.colSpan === other.colSpan &&
entry.rowSpan === other.rowSpan &&
- entry.order === other.order
+ entry.order === other.order &&
+ entry.col === other.col &&
+ entry.row === other.row
);
});
}
diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts
index f8b30f7..be6a14e 100644
--- a/src/features/workspaces/tile-layout-store.test.ts
+++ b/src/features/workspaces/tile-layout-store.test.ts
@@ -29,12 +29,21 @@ function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEn
}
describe("createTileLayoutStore", () => {
- it("loads the persisted document at creation", () => {
+ it("loads the persisted document at creation (v1 migrates to v2 in memory)", () => {
const persistence = memoryStore({ version: 1, tiles: [tile("a", 0, 2, 2)] });
const store = createTileLayoutStore(persistence);
expect(store.entries).toEqual([tile("a", 0, 2, 2)]);
});
+ it("loads a v2 document with anchors intact", () => {
+ const persistence = memoryStore({
+ version: 2,
+ tiles: [{ ...tile("a", 0), col: 2, row: 3 }],
+ });
+ const store = createTileLayoutStore(persistence);
+ expect(store.entries).toEqual([{ ...tile("a", 0), col: 2, row: 3 }]);
+ });
+
it("sanitizes a corrupt persisted document to empty", () => {
const persistence = memoryStore(null);
// The adapter's load is untyped at the boundary — simulate garbage.
@@ -57,11 +66,26 @@ describe("createTileLayoutStore", () => {
expect(persistence.saved).toHaveLength(1);
// The max order counts KEPT entries (the dropped one doesn't reserve a slot).
expect(persistence.saved[0]).toEqual({
- version: 1,
+ version: 2,
tiles: [tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }],
});
});
+ it("sync keeps pinned anchors (a deleted tile's cells become gaps)", () => {
+ const persistence = memoryStore({
+ version: 2,
+ tiles: [
+ { ...tile("a", 0), col: 1, row: 1 },
+ { ...tile("gone", 1), col: 1, row: 2 },
+ ],
+ });
+ const store = createTileLayoutStore(persistence);
+
+ store.sync(["a"]);
+
+ expect(store.entries).toEqual([{ ...tile("a", 0), col: 1, row: 1 }]);
+ });
+
it("sync is a no-op (no write) when the merge changes nothing", () => {
const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] });
const store = createTileLayoutStore(persistence);
@@ -82,20 +106,34 @@ describe("createTileLayoutStore", () => {
expect(persistence.saved.at(-1)?.tiles).toEqual([tile("a", 0, 4, 2)]);
});
- it("move persists the reordered layout", () => {
+ it("placeAtCell commits the displacement and pins EVERY tile", () => {
const persistence = memoryStore({
version: 1,
- tiles: [tile("a", 0), tile("b", 1), tile("c", 2)],
+ tiles: [tile("a", 0), tile("b", 1)],
});
const store = createTileLayoutStore(persistence);
- store.move("c", 0);
+ // a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell.
+ store.placeAtCell("a", { col: 2, row: 1 }, WIDE);
+
+ const entries = store.entries;
+ expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 });
+ expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 });
+ // Written as v2.
+ expect(persistence.saved.at(-1)?.version).toBe(2);
+ });
+
+ it("placeAtCell with an unknown id is a no-op (no write)", () => {
+ const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] });
+ const store = createTileLayoutStore(persistence);
+
+ store.placeAtCell("nope", { col: 1, row: 1 }, WIDE);
- expect(store.entries.map((t) => t.id)).toEqual(["c", "a", "b"]);
- expect(store.entries.map((t) => t.order)).toEqual([0, 1, 2]);
+ expect(persistence.saved).toHaveLength(0);
+ expect(store.entries).toEqual([tile("a", 0)]);
});
- it("placeFirstEmpty puts a new id in the first empty slot's fill position", () => {
+ it("placeFirstEmpty PINS the new id at the first empty slot", () => {
const persistence = memoryStore({
version: 1,
tiles: [tile("a", 0, 1, 2), tile("b", 1)],
@@ -104,27 +142,33 @@ describe("createTileLayoutStore", () => {
store.placeFirstEmpty("new", WIDE);
- // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → appended.
- expect(store.entries.map((t) => t.id)).toEqual(["a", "b", "new"]);
+ // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned.
+ expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 });
expect(persistence.saved.at(-1)?.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]);
});
it("placeFirstEmpty is idempotent for an id sync already appended (the refresh race)", () => {
const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] });
const store = createTileLayoutStore(persistence);
- store.sync(["a", "x"]); // the refresh appended the new workspace at the end
- const before = store.entries;
+ store.sync(["a", "x"]); // the refresh appended the new workspace (unpinned)
store.placeFirstEmpty("x", WIDE);
+ const pinned = store.entries;
+ store.placeFirstEmpty("x", WIDE); // the create flow's re-call must be stable
- expect(store.entries).toEqual(before);
+ expect(store.entries).toEqual(pinned);
expect(store.entries.filter((t) => t.id === "x")).toHaveLength(1);
+ // x is PINNED at the first empty slot: a@(1,1) → (1,2).
+ expect(store.entries.find((t) => t.id === "x")).toMatchObject({ col: 1, row: 2 });
});
- it("sortByRecentActivity re-applies recent-activity order and persists", () => {
+ it("sortByRecentActivity re-applies recent-activity order, CLEARs pins, persists", () => {
const persistence = memoryStore({
- version: 1,
- tiles: [tile("a", 0), tile("b", 1)],
+ version: 2,
+ tiles: [
+ { ...tile("a", 0), col: 1, row: 1 },
+ { ...tile("b", 1), col: 2, row: 3 },
+ ],
});
const store = createTileLayoutStore(persistence);
@@ -136,5 +180,6 @@ describe("createTileLayoutStore", () => {
);
expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]);
+ expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true);
});
});
diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte
index be630d2..fb0be0a 100644
--- a/src/features/workspaces/ui/HomeHeader.svelte
+++ b/src/features/workspaces/ui/HomeHeader.svelte
@@ -114,7 +114,7 @@
</button>
<ul class="dropdown-content menu z-50 w-52 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-xl">
<li>
- <button type="button" onclick={onSort}>Sort by recent activity</button>
+ <button type="button" onclick={onSort}>Reset arrangement by recent activity</button>
</li>
</ul>
</div>
diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts
index a0afc52..b4ef6c4 100644
--- a/src/features/workspaces/ui/HomeHeader.test.ts
+++ b/src/features/workspaces/ui/HomeHeader.test.ts
@@ -36,7 +36,7 @@ describe("HomeHeader", () => {
render(HomeHeader, { props: { ...p, withSort: true } });
await user.click(screen.getByRole("button", { name: "More actions" }));
- await user.click(screen.getByRole("button", { name: "Sort by recent activity" }));
+ await user.click(screen.getByRole("button", { name: "Reset arrangement by recent activity" }));
expect(p.onSort).toHaveBeenCalledTimes(1);
});
diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte
index ceae133..46b3c4e 100644
--- a/src/features/workspaces/ui/HomeTileGrid.svelte
+++ b/src/features/workspaces/ui/HomeTileGrid.svelte
@@ -1,25 +1,21 @@
<script lang="ts">
import type { WorkspaceEntry } from "@dispatch/wire";
+ import { gsap } from "gsap";
+ import { Flip } from "gsap/Flip";
+ import { tick } from "svelte";
import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller";
- import type { GridGeometry, Placement, Span } from "../logic/tile-layout";
+ import {
+ displacePlace,
+ type Cell,
+ type GridGeometry,
+ type Placement,
+ type Span,
+ } from "../logic/tile-layout";
import { tileTone, type TileTone } from "../logic/view-model";
import type { WorkspaceStore } from "../store.svelte";
import WorkspaceTile from "./WorkspaceTile.svelte";
- /**
- * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see
- * the literal `bg-*`/`text-*-content` names to emit them). A tile is its
- * tone's SATURATED color with the tone's `-content` text, like Win8's
- * colored tiles on the dark Start canvas.
- */
- const TONE_CLASSES: Record<TileTone, string> = {
- primary: "bg-primary text-primary-content border-primary",
- secondary: "bg-secondary text-secondary-content border-secondary",
- accent: "bg-accent text-accent-content border-accent",
- info: "bg-info text-info-content border-info",
- success: "bg-success text-success-content border-success",
- warning: "bg-warning text-warning-content border-warning",
- };
+ gsap.registerPlugin(Flip);
let {
geometry,
@@ -32,11 +28,11 @@
hasActive,
onOpen,
onSelect,
- onReorder,
+ onPlace,
onRenameEnd,
}: {
geometry: GridGeometry;
- /** Packed placements (in pack order — the manual tile order). */
+ /** Packed placements of the COMMITTED layout (the drag preview derives). */
placements: readonly Placement[];
workspacesById: ReadonlyMap<string, WorkspaceEntry>;
/** Forwarded to tiles for the inline rename (semantics preserved). */
@@ -49,8 +45,8 @@
hasActive?: (workspaceId: string) => boolean;
onOpen: (id: string) => void;
onSelect: (id: string) => void;
- /** The drag dropped `id` at `toIndex` in the manual order (-1 = nowhere). */
- onReorder: (id: string, toIndex: number) => void;
+ /** The drag dropped `id` at `cell` (null = the pointer never resolved to a cell). */
+ onPlace: (id: string, cell: Cell | null) => void;
onRenameEnd: () => void;
} = $props();
@@ -59,6 +55,10 @@
const LONG_PRESS_MS = 500;
let phase = $state<DragPhase>({ kind: "idle" });
let longPressTimer: ReturnType<typeof setTimeout> | undefined;
+ /** Pointer position where the current press began (the follow transform base). */
+ let pressOrigin = $state<{ x: number; y: number } | null>(null);
+ /** The tile mid-settle after a drop (excluded from FLIP until it lands). */
+ let settling = $state<string | null>(null);
function armLongPress(): void {
disarmLongPress();
@@ -76,25 +76,64 @@
}
function dispatch(event: DragEvent): void {
+ const wasDragging = phase.kind === "dragging" ? phase.id : null;
const transition = nextDragPhase(phase, event, { dragThresholdPx: DRAG_THRESHOLD_PX });
phase = transition.phase;
const emit = transition.emit;
if (emit) {
if (emit.kind === "tap") onOpen(emit.id);
else if (emit.kind === "lift") onSelect(emit.id);
- else if (emit.kind === "drop" && emit.targetIndex >= 0) onReorder(emit.id, emit.targetIndex);
+ else if (emit.kind === "drop") drop(emit.id, emit.targetCell);
+ }
+ if (phase.kind === "idle") {
+ // The gesture ended (tap/drop/cancel). A DROP settles via `drop` above;
+ // a CANCEL mid-drag flies the tile back to its committed slot.
+ if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging);
+ pressOrigin = null;
}
// Keep the long-press armed exactly while a TOUCH press is undecided.
if (phase.kind === "pressing" && phase.touch) armLongPress();
else disarmLongPress();
}
- /** The hovered tile's index in the manual order (-1 = not over a tile). */
- function hoveredIndex(x: number, y: number): number {
- const hit = document.elementFromPoint(x, y)?.closest("[data-tile-id]");
- const id = hit?.getAttribute("data-tile-id");
- if (id === null || id === undefined) return -1;
- return placements.findIndex((placement) => placement.id === id);
+ /** The tile being dragged (or settling after a drop) follows no FLIP. */
+ const excludedFromFlip = $derived(
+ new Set(
+ [phase.kind === "dragging" ? phase.id : null, settling].filter(
+ (id): id is string => id !== null,
+ ),
+ ),
+ );
+
+ /** Commit the drop, then settle the tile from the pointer onto its new cell. */
+ function drop(id: string, cell: Cell | null): void {
+ if (cell !== null) onPlace(id, cell);
+ flyBack(id);
+ }
+
+ /**
+ * Animate the tile's follow transform to identity at its (possibly new)
+ * committed slot — the drop settle AND the cancel/Escape fly-back. The tile
+ * is excluded from Flip until the animation completes so the two never
+ * fight over the same transform.
+ */
+ function flyBack(id: string): void {
+ const el = tileEls.get(id);
+ if (!el) return;
+ settling = id;
+ void tick().then(() => {
+ gsap.to(el, {
+ x: 0,
+ y: 0,
+ scale: 1,
+ duration: 0.2,
+ ease: "power2.out",
+ onComplete: () => {
+ gsap.set(el, { clearProps: "transform" });
+ settling = null;
+ },
+ });
+ });
}
/** Interactive targets inside a tile (the rename input) own their gestures. */
@@ -107,6 +146,7 @@
if (e.button !== 0 || insideInteractive(e.target)) return;
// Capture so fast drags keep the pointer events on this element.
(e.currentTarget as HTMLElement)?.setPointerCapture?.(e.pointerId);
+ pressOrigin = { x: e.clientX, y: e.clientY };
dispatch({
type: "pointer-down",
id,
@@ -122,7 +162,7 @@
type: "pointer-move",
x: e.clientX,
y: e.clientY,
- targetIndex: hoveredIndex(e.clientX, e.clientY),
+ targetCell: cellAt(e.clientX, e.clientY),
});
}
@@ -131,9 +171,72 @@
dispatch({ type: "pointer-up" });
}
- // ── Tile element registry (for the FLIP reflow animation) ─────────────────
+ // Escape mid-drag: cancel BEFORE the page-level Escape unwinds the
+ // selection — the committed layout is untouched and the tile flies home.
+ function windowKeydown(e: KeyboardEvent): void {
+ if (e.key !== "Escape" || phase.kind === "idle") return;
+ e.stopPropagation();
+ dispatch({ type: "cancel" });
+ }
+
+ // ── Cell hit math (pointer → grid cell; empty cells are drop targets) ─────
+ let gridEl = $state<HTMLDivElement | undefined>();
+
+ const pitchX = $derived(geometry.unit.width + geometry.unit.gap);
+ const pitchY = $derived(geometry.unit.height + geometry.unit.gap);
+
+ function cellAt(clientX: number, clientY: number): Cell | null {
+ const el = gridEl;
+ if (!el) return null;
+ const rect = el.getBoundingClientRect();
+ const wide = geometry.mode === "wide";
+ // Wide: fixed-width columns at the pitch. Tall: fluid 1fr columns.
+ const cellW = wide ? pitchX : rect.width / geometry.flowCols;
+ const col = Math.max(1, Math.floor((clientX - rect.left) / cellW) + 1);
+ const row = Math.max(1, Math.floor((clientY - rect.top) / pitchY) + 1);
+ return {
+ col: wide ? col : Math.min(col, geometry.flowCols),
+ row: wide ? Math.min(row, geometry.flowRows) : row,
+ };
+ }
+
+ // ── The drag preview: COMMITTED placements + displacement, fresh per cell ─
+ /** The dragged tile's id while a drag is in flight (else null). */
+ const draggedId = $derived<string | null>(
+ phase.kind === "dragging" ? phase.id : null,
+ );
+ const draggedPlacement = $derived(
+ draggedId !== null
+ ? (placements.find((placement) => placement.id === draggedId) ?? null)
+ : null,
+ );
+
+ /** The pointer's hovered cell while dragging (else null). */
+ const hoveredCell = $derived<Cell | null>(
+ phase.kind === "dragging" ? phase.targetCell : null,
+ );
+
+ /** The landing cell: where the pointer is, else the tile's committed slot. */
+ const targetCell = $derived<Cell | null>(
+ hoveredCell ??
+ (draggedPlacement ? { col: draggedPlacement.col, row: draggedPlacement.row } : null),
+ );
+
+ /**
+ * What to render: while dragging, the displacement of the COMMITTED layout
+ * at the target cell — recomputed from committed on EVERY hover change (as
+ * if the user moved straight from the original to here); the FLIP below
+ * animates from wherever the tiles currently are. Otherwise the committed
+ * placements.
+ */
+ const rendered = $derived(
+ draggedPlacement && targetCell && draggedId !== null
+ ? displacePlace(placements, draggedId, targetCell, geometry)
+ : placements,
+ );
+
+ // ── FLIP reflow (GSAP Flip — re-entrant: captures mid-flight positions) ───
let tileEls = new Map<string, HTMLElement>();
- let lastRects = new Map<string, { x: number; y: number }>();
function trackTile(el: HTMLElement, id: string): { destroy: () => void } {
tileEls.set(id, el);
@@ -144,32 +247,31 @@
};
}
- // FLIP: measure after a placements/geometry change and play the inverted
- // delta — reordering edits the ORDER, the reflow is always derived.
+ let flipState: ReturnType<typeof Flip.getState> | null = null;
+
+ $effect.pre(() => {
+ // Track the layout inputs; the DOM read below is intentionally untracked.
+ void rendered
+ .map((p) => `${p.id}:${p.col},${p.row},${p.colSpan}x${p.rowSpan}`)
+ .join("|");
+ void geometry.mode;
+ flipState = Flip.getState(
+ [...tileEls].filter(([id]) => !excludedFromFlip.has(id)).map(([, el]) => el),
+ );
+ });
+
$effect(() => {
- // Track the layout inputs (the DOM reads below are untracked).
- const ids = new Set(placements.map((placement) => placement.id));
+ void rendered;
void geometry.mode;
- const rects = new Map<string, { x: number; y: number }>();
- for (const [id, el] of tileEls) {
- if (!ids.has(id)) continue;
- const rect = el.getBoundingClientRect();
- rects.set(id, { x: rect.left, y: rect.top });
- }
- for (const [id, prev] of lastRects) {
- const next = rects.get(id);
- const el = tileEls.get(id);
- if (!next || !el) continue;
- const dx = prev.x - next.x;
- const dy = prev.y - next.y;
- if ((dx !== 0 || dy !== 0) && typeof el.animate === "function") {
- el.animate(
- [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
- { duration: 200, easing: "ease-out" },
- );
- }
- }
- lastRects = rects;
+ const state = flipState;
+ flipState = null;
+ if (!state) return;
+ Flip.from(state, {
+ duration: 0.25,
+ ease: "power2.inOut",
+ nested: true,
+ onEnter: (elements) => gsap.fromTo(elements, { opacity: 0 }, { opacity: 1, duration: 0.2 }),
+ });
});
// ── Wide-mode horizontal panning: wheel + pan arrow + scroll indicator ────
@@ -209,15 +311,34 @@
}
// ── Grid + tile styles (the geometry decides; no breakpoint classes) ──────
- const gridStyle = $derived(
- geometry.mode === "wide"
- ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` +
+ const dragging = $derived(phase.kind === "dragging");
+
+ const gridStyle = $derived.by(() => {
+ const base =
+ geometry.mode === "wide"
+ ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` +
` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` +
` gap: ${geometry.unit.gap}px; width: max-content;`
- : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` +
+ : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` +
` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` +
- ` gap: ${geometry.unit.gap}px;`,
- );
+ ` gap: ${geometry.unit.gap}px;`;
+ if (!dragging) return base;
+ // The drop-target dots: one faint dot at each unit cell's CENTER, painted
+ // as a background whose tile is the cell pitch. One extra pitch of padding
+ // past the content end gives the append-slot a dot too.
+ const dotSize =
+ geometry.mode === "wide" ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`;
+ const dotX = geometry.mode === "wide" ? geometry.unit.width / 2 : 0;
+ const dot =
+ ` background-image: radial-gradient(circle 2.5px,` +
+ ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` +
+ ` background-size: ${dotSize} ${pitchY}px;` +
+ ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` +
+ (geometry.mode === "wide"
+ ? ` padding-right: ${pitchX}px;`
+ : ` padding-bottom: ${pitchY}px;`);
+ return base + dot;
+ });
function spanFor(placement: Placement): Span {
if (preview && preview.id === placement.id) {
@@ -230,8 +351,48 @@
const span = spanFor(placement);
return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`;
}
+
+ /** The dragged tile's follow transform (it keeps its committed slot). */
+ function followStyle(placement: Placement): string {
+ if (draggedId !== placement.id || !pressOrigin || phase.kind !== "dragging") return "";
+ const dx = phase.x - pressOrigin.x;
+ const dy = phase.y - pressOrigin.y;
+ return `transform: translate(${dx}px, ${dy}px) scale(1.05);`;
+ }
+
+ // ── The drop ghost: a dashed footprint at the target anchor ───────────────
+ const ghostStyle = $derived.by(() => {
+ if (!dragging || !targetCell || !draggedPlacement) return null;
+ const span = spanFor(draggedPlacement);
+ const wide = geometry.mode === "wide";
+ const h = span.rowSpan * pitchY - geometry.unit.gap;
+ const x = wide
+ ? `${(targetCell.col - 1) * pitchX}px`
+ : `calc(${((targetCell.col - 1) / geometry.flowCols) * 100}%)`;
+ const width = wide
+ ? `${span.colSpan * pitchX - geometry.unit.gap}px`
+ : `calc(${(span.colSpan / geometry.flowCols) * 100}% - ${(geometry.unit.gap * (geometry.flowCols - span.colSpan)) / geometry.flowCols}px)`;
+ return `left: ${x}; top: ${(targetCell.row - 1) * pitchY}px; width: ${width}; height: ${h}px;`;
+ });
+
+ /**
+ * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see
+ * the literal `bg-*`/`text-*-content` names to emit them). A tile is its
+ * tone's SATURATED color with the tone's `-content` text, like Win8's
+ * colored tiles on the dark Start canvas.
+ */
+ const TONE_CLASSES: Record<TileTone, string> = {
+ primary: "bg-primary text-primary-content border-primary",
+ secondary: "bg-secondary text-secondary-content border-secondary",
+ accent: "bg-accent text-accent-content border-accent",
+ info: "bg-info text-info-content border-info",
+ success: "bg-success text-success-content border-success",
+ warning: "bg-warning text-warning-content border-warning",
+ };
</script>
+<svelte:window onkeydowncapture={windowKeydown} />
+
<div class="relative min-h-0 flex-1">
<div
bind:this={scrollerEl}
@@ -242,21 +403,40 @@
onscroll={updateScroll}
onwheel={handleWheel}
>
- <div class="grid" style={gridStyle}>
- {#each placements as placement (placement.id)}
+ <div bind:this={gridEl} class="relative grid" style={gridStyle}>
+ {#if ghostStyle && draggedPlacement}
+ <!-- The landing footprint (the dragged tile's tone, dashed). -->
+ <div
+ aria-hidden="true"
+ class="pointer-events-none absolute z-0 rounded-box border-2 border-dashed opacity-70"
+ class:border-primary={tileTone(draggedPlacement.id) === "primary"}
+ class:border-secondary={tileTone(draggedPlacement.id) === "secondary"}
+ class:border-accent={tileTone(draggedPlacement.id) === "accent"}
+ class:border-info={tileTone(draggedPlacement.id) === "info"}
+ class:border-success={tileTone(draggedPlacement.id) === "success"}
+ class:border-warning={tileTone(draggedPlacement.id) === "warning"}
+ style={ghostStyle}
+ ></div>
+ {/if}
+ {#each rendered as placement (placement.id)}
{@const ws = workspacesById.get(placement.id)}
{#if ws}
+ {@const isDragged = draggedId === placement.id}
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div
data-tile-id={placement.id}
- class={`flex select-none flex-col overflow-hidden rounded-box border shadow-md transition-[transform,box-shadow] duration-150 hover:-translate-y-0.5 hover:shadow-xl ${
+ class={`flex select-none flex-col overflow-hidden rounded-box border transition-[box-shadow] duration-150 ${
TONE_CLASSES[tileTone(placement.id)]
} ${
selectedId === placement.id
? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100"
: "border-transparent"
- } ${phase.kind === "dragging" && phase.id === placement.id ? "opacity-40" : ""}`}
- style={`${wrapperStyle(placement)} scroll-snap-align: start;`}
+ } ${
+ isDragged
+ ? "pointer-events-none relative z-40 shadow-2xl"
+ : "shadow-md hover:-translate-y-0.5 hover:shadow-xl"
+ }`}
+ style={`${wrapperStyle(placement)} ${isDragged ? followStyle(placement) : ""} scroll-snap-align: start;`}
role="button"
tabindex="0"
aria-label={`Open workspace ${ws.title}`}
@@ -316,13 +496,3 @@
</div>
{/if}
</div>
-
-{#if phase.kind === "dragging"}
- <!-- The drag ghost follows the pointer (transient; never a data change). -->
- <div
- class="pointer-events-none fixed z-50 rounded-box border border-base-content/25 bg-base-100/95 px-3 py-2 text-sm font-bold shadow-2xl"
- style={`left: ${phase.x + 10}px; top: ${phase.y + 10}px;`}
- >
- {workspacesById.get(phase.id)?.title ?? phase.id}
- </div>
-{/if}
diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts
index 3229d4e..d70c673 100644
--- a/src/features/workspaces/ui/HomeTileGrid.test.ts
+++ b/src/features/workspaces/ui/HomeTileGrid.test.ts
@@ -41,7 +41,7 @@ function baseProps() {
store: fakeStore(),
onOpen: vi.fn(),
onSelect: vi.fn(),
- onReorder: vi.fn(),
+ onPlace: vi.fn(),
onRenameEnd: vi.fn(),
};
}
@@ -98,32 +98,51 @@ describe("HomeTileGrid", () => {
expect(props.onOpen).not.toHaveBeenCalled();
});
- it("dragging shows the transient ghost, and dropping reorders (when over a target)", async () => {
+ it("dragging shows the ghost + follow transform, and dropping PLACES at the hovered cell", async () => {
const props = baseProps();
const { container } = render(HomeTileGrid, {
props: { ...props, placements: [placement("a", 1, 1), placement("b", 2, 1)] },
});
const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
- // jsdom has no elementFromPoint hit-testing → targetIndex stays -1 (no-op),
- // but the GHOST must appear while dragging and disappear on drop.
- Object.defineProperty(document, "elementFromPoint", {
- configurable: true,
- value: () => null,
- });
+ // The cell math resolves against the grid element's rect (jsdom: 0s).
fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" });
fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 });
- expect(container.querySelector(".pointer-events-none.fixed")).not.toBeNull();
+ // The dashed landing ghost appears…
+ const ghost = container.querySelector("[aria-hidden='true'].border-dashed");
+ expect(ghost).not.toBeNull();
+ // …and the dragged tile itself follows the pointer (transform on the tile).
+ const tile = container.querySelector("[data-tile-id='a']");
+ expect(tile?.getAttribute("style")).toContain("translate(");
fireEvent.pointerUp(wrapper);
- expect(container.querySelector(".pointer-events-none.fixed")).toBeNull();
- // No hit target → no reorder call.
- expect(props.onReorder).not.toHaveBeenCalled();
+ // The ghost + follow disappear on drop…
+ expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull();
+ // …and the drop committed a PLACE at the hovered cell (a lands where it
+ // already was — the pointer hovered its own slot; a no-move drop).
+ expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 });
// And no navigation either (a drag is not a tap).
expect(props.onOpen).not.toHaveBeenCalled();
});
+ it("Escape mid-drag CANCELS: no place commits and the tile un-lifts", async () => {
+ const props = baseProps();
+ const { container } = render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" });
+ fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 });
+
+ fireEvent.keyDown(window, { key: "Escape" });
+
+ expect(props.onPlace).not.toHaveBeenCalled();
+ // The drag visuals are gone (the tile is no longer lifted).
+ expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull();
+ });
+
it("a touch long-press lifts the tile and selects it (never opens)", async () => {
const props = baseProps();
render(HomeTileGrid, {
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index cbf762f..7c0db85 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -276,7 +276,7 @@
{...(hasActive ? { hasActive } : {})}
onOpen={openWorkspace}
onSelect={select}
- onReorder={(id, toIndex) => tiles.move(id, toIndex)}
+ onPlace={(id, cell) => cell !== null && tiles.placeAtCell(id, cell, geometry)}
onRenameEnd={() => (renamingId = null)}
/>
{/if}