summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 11:47:18 +0900
committerAdam Malczewski <[email protected]>2026-09-07 11:47:18 +0900
commit754450b539818f9ad94c9586888973b0edd74b68 (patch)
treed852f9a8910f223e15a5e11ad04868b0d0a29804 /src/features/workspaces/logic
parent719f0038dbb09f0fe9b073c3dc8e4f403815223d (diff)
downloaddispatch-web-754450b539818f9ad94c9586888973b0edd74b68.tar.gz
dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.zip
feat(workspaces): Win8 Start-style tile-grid home page
Rebuild the workspace home as a Windows 8 Start-screen-style tile grid, replacing the max-w-3xl vertical card list. Pure core (zero DOM/Svelte/fetch, fully unit-tested): - logic/tile-layout.ts — geometryFor(width, height) resolves WIDE (4 fixed ~120px rows, column-wise flow, 4x2 span cap) or TALL (2 fixed columns, row-wise flow, 2x2 cap); pack() is the ONLY order->coordinates transform (greedy first-fit in fill order, hole-free, render-clamped per geometry); firstEmptySlot/insertAtFirstEmptySlot/mergeLayout/resizeTile/moveTile/ sortByRecentActivity/sanitizeLayout. Persisted data is orientation-independent {version:1, tiles:[{id,colSpan,rowSpan,order}]} — order + spans only, never positions; the same data renders in either geometry (spans clamp for the render, persisted values untouched). - logic/drag-controller.ts — pure pointer/press state machine: mouse threshold-drag + tap-open; touch long-press lifts + selects, pre-lift movement cancels so vertical scrolling never fights the gesture. Shell: - tile-layout-store.svelte.ts — thin reactive wrapper persisting through the injected createLocalStore adapter (dispatch.workspaces.tiles), ops: sync/resize/move/placeFirstEmpty/sortByRecentActivity. - ui/HomeTileGrid.svelte — CSS-grid tile canvas (wide: horizontal scroll + snap + wheel pan + pan arrow + scroll indicator; tall: native vertical scroll), FLIP-animated reflow, drag ghost, transient resize preview. - ui/HomeHeader.svelte — fixed chrome: Dispatch display title, New workspace popover (same slug validation; creates via create-on-miss and does NOT navigate — the new tile lands in the first empty slot in fill order and enters rename mode), global Plugins button, sort-by-recent overflow; compact icon buttons + bottom sheet on tall screens. - ui/WorkspaceTile.svelte — tile face (title, /slug, conversation count, relative last-activity, live active dots, starred tint); inline rename with the old card's semantics. - ui/TileActionBar.svelte — slides up on select (right-click / long-press): Open, Rename, Resize, Star/Unstar, Details, Plugins, Delete (old confirm + store call preserved). - ui/ResizePicker.svelte — Word-style insert-table hover grid (wide 4x2 / tall 2x2) with arrow/Enter/Escape support and ghost preview on commit. - ui/WorkspaceDetails.svelte — flyout holding the old card's default-cwd input + default-computer select (same store calls + error states). - WorkspacesHome.svelte orchestrates; App.svelte untouched (same props). - WorkspaceCard.svelte is removed (replaced by the tile system). Manual tile order rules; starred workspaces get a tint (star is no longer a sort key); workspaces unknown to the layout append at the end. vitest: jsdom gains PointerEvent (MouseEvent-based) + Element.animate stubs — the pointer logic is the outermost edge for the drag tests. Verification: svelte-check 0/0, vitest 1655 passed (102 files, twice), biome clean, vite build OK.
Diffstat (limited to 'src/features/workspaces/logic')
-rw-r--r--src/features/workspaces/logic/drag-controller.test.ts173
-rw-r--r--src/features/workspaces/logic/drag-controller.ts159
-rw-r--r--src/features/workspaces/logic/tile-layout.test.ts440
-rw-r--r--src/features/workspaces/logic/tile-layout.ts427
4 files changed, 1199 insertions, 0 deletions
diff --git a/src/features/workspaces/logic/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts
new file mode 100644
index 0000000..d6dd760
--- /dev/null
+++ b/src/features/workspaces/logic/drag-controller.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from "vitest";
+import { type DragPhase, type DragTransition, nextDragPhase } from "./drag-controller";
+
+const OPTS = { dragThresholdPx: 6 };
+
+function down(over: Partial<Parameters<typeof nextDragPhase>[1] & { type: "pointer-down" }> = {}) {
+ return {
+ type: "pointer-down" as const,
+ id: "t1",
+ x: 10,
+ y: 10,
+ touch: false,
+ ...over,
+ };
+}
+
+function press(touch = false): DragPhase {
+ return nextDragPhase({ kind: "idle" }, down({ touch }), OPTS).phase;
+}
+
+describe("nextDragPhase — mouse", () => {
+ it("a pointer-down enters the undecided pressing state", () => {
+ expect(press()).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false });
+ });
+
+ it("movement past the threshold starts a drag", () => {
+ const transition = nextDragPhase(
+ press(),
+ { type: "pointer-move", x: 20, y: 12, targetIndex: 3 },
+ OPTS,
+ );
+ expect(transition.phase).toEqual({
+ kind: "dragging",
+ id: "t1",
+ x: 20,
+ y: 12,
+ targetIndex: 3,
+ touch: false,
+ });
+ expect(transition.emit).toBeUndefined();
+ });
+
+ it("movement within the threshold stays pressing", () => {
+ const transition = nextDragPhase(
+ press(),
+ { type: "pointer-move", x: 12, y: 14, targetIndex: 0 },
+ OPTS,
+ );
+ expect(transition.phase).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false });
+ });
+
+ it("release without moving emits a tap (open the workspace)", () => {
+ const transition = nextDragPhase(press(), { type: "pointer-up" }, OPTS);
+ expect(transition.phase).toEqual({ kind: "idle" });
+ expect(transition.emit).toEqual({ kind: "tap", id: "t1" });
+ });
+
+ 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 },
+ OPTS,
+ );
+ transition = nextDragPhase(
+ transition.phase,
+ { type: "pointer-move", x: 31, y: 31, targetIndex: 5 },
+ OPTS,
+ );
+ transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS);
+ expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetIndex: 5 });
+ expect(transition.phase).toEqual({ kind: "idle" });
+ });
+});
+
+describe("nextDragPhase — touch", () => {
+ it("a long-press lifts the tile and emits lift (arm the drag + select)", () => {
+ const transition = nextDragPhase(press(true), { type: "long-press" }, OPTS);
+ expect(transition.emit).toEqual({ kind: "lift", id: "t1" });
+ expect(transition.phase).toEqual({
+ kind: "dragging",
+ id: "t1",
+ x: 10,
+ y: 10,
+ targetIndex: -1,
+ touch: true,
+ });
+ });
+
+ 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 },
+ OPTS,
+ );
+ expect(transition.phase).toEqual({ kind: "idle" });
+ expect(transition.emit).toBeUndefined();
+ });
+
+ it("movement within the threshold before the lift keeps pressing", () => {
+ const transition = nextDragPhase(
+ press(true),
+ { type: "pointer-move", x: 13, y: 12, targetIndex: 0 },
+ OPTS,
+ );
+ expect(transition.phase?.kind).toBe("pressing");
+ });
+
+ it("after the lift, movement drags (never cancels)", () => {
+ let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS);
+ transition = nextDragPhase(
+ transition.phase,
+ { type: "pointer-move", x: 10, y: 90, targetIndex: 4 },
+ OPTS,
+ );
+ expect(transition.phase).toEqual({
+ kind: "dragging",
+ id: "t1",
+ x: 10,
+ y: 90,
+ targetIndex: 4,
+ touch: true,
+ });
+ });
+
+ it("a long-press never lifts a mouse press (desktop selects via right-click)", () => {
+ const transition = nextDragPhase(press(false), { type: "long-press" }, OPTS);
+ expect(transition.emit).toBeUndefined();
+ expect(transition.phase?.kind).toBe("pressing");
+ });
+
+ 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 });
+ });
+});
+
+describe("nextDragPhase — cancellations", () => {
+ it("cancel returns to idle from any phase without emitting", () => {
+ for (const phase of [
+ { kind: "idle" as const },
+ press(),
+ nextDragPhase(press(), { type: "pointer-move", x: 40, y: 40, targetIndex: 1 }, OPTS).phase,
+ ]) {
+ const transition = nextDragPhase(phase, { type: "cancel" }, OPTS);
+ expect(transition.phase).toEqual({ kind: "idle" });
+ expect(transition.emit).toBeUndefined();
+ }
+ });
+
+ it("events in idle are inert", () => {
+ expect(nextDragPhase({ kind: "idle" }, { type: "pointer-up" }, OPTS).phase).toEqual({
+ kind: "idle",
+ });
+ expect(
+ nextDragPhase({ kind: "idle" }, { type: "pointer-move", x: 1, y: 1, targetIndex: 0 }, OPTS)
+ .phase,
+ ).toEqual({ kind: "idle" });
+ expect(nextDragPhase({ kind: "idle" }, { type: "long-press" }, OPTS).phase).toEqual({
+ kind: "idle",
+ });
+ });
+
+ 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 },
+ OPTS,
+ ).phase;
+ const transition = nextDragPhase(dragging, down({ id: "t2" }), OPTS);
+ expect(transition.phase).toEqual({ kind: "pressing", id: "t2", x: 10, y: 10, touch: false });
+ });
+});
diff --git a/src/features/workspaces/logic/drag-controller.ts b/src/features/workspaces/logic/drag-controller.ts
new file mode 100644
index 0000000..03dcade
--- /dev/null
+++ b/src/features/workspaces/logic/drag-controller.ts
@@ -0,0 +1,159 @@
+/**
+ * Pure drag/press state machine for the home tile grid — zero DOM, zero
+ * timers (the component schedules the long-press timer and dispatches its
+ * firing as an event), zero Svelte.
+ *
+ * Pointer semantics:
+ * - **Mouse**: press → move past the threshold = DRAG; release without
+ * moving = a TAP (open the workspace). Selection is right-click.
+ * - **Touch**: press → (no movement past the threshold) long-press = LIFT
+ * (arm the drag + select the tile) → further moves drag. Moving past the
+ * threshold BEFORE the lift cancels the press — vertical scrolling wins,
+ * 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).
+ */
+
+/** Where the interaction currently stands. */
+export type DragPhase =
+ | { readonly kind: "idle" }
+ /** Pointer is down; not yet decided between tap / drag / long-press. */
+ | {
+ readonly kind: "pressing";
+ readonly id: string;
+ readonly x: number;
+ readonly y: number;
+ readonly touch: boolean;
+ }
+ /** The tile is lifted and following the pointer (touch: after the long-press). */
+ | {
+ readonly kind: "dragging";
+ readonly id: string;
+ readonly x: number;
+ readonly y: number;
+ readonly targetIndex: number;
+ readonly touch: boolean;
+ };
+
+/** An action the component should perform as a result of a transition. */
+export type DragEmit =
+ | { readonly kind: "tap"; readonly id: string }
+ | { readonly kind: "lift"; readonly id: string }
+ | { readonly kind: "drop"; readonly id: string; readonly targetIndex: number };
+
+/** Events the component dispatches (DOM events + the long-press timer firing). */
+export type DragEvent =
+ | {
+ readonly type: "pointer-down";
+ readonly id: string;
+ readonly x: number;
+ readonly y: number;
+ readonly touch: boolean;
+ }
+ | {
+ readonly type: "pointer-move";
+ readonly x: number;
+ readonly y: number;
+ readonly targetIndex: number;
+ }
+ | { readonly type: "pointer-up" }
+ | { readonly type: "long-press" }
+ /** pointercancel / blur / the tile unmounting / Escape. */
+ | { readonly type: "cancel" };
+
+export interface DragOptions {
+ /** Pointer travel (px) that turns a press into a drag (or cancels a touch press). */
+ readonly dragThresholdPx: number;
+}
+
+export interface DragTransition {
+ readonly phase: DragPhase;
+ readonly emit?: DragEmit;
+}
+
+const IDLE: DragPhase = { kind: "idle" };
+
+/** Advance the state machine. Pure: phase + event in, NEXT phase + emit out. */
+export function nextDragPhase(
+ phase: DragPhase,
+ event: DragEvent,
+ options: DragOptions,
+): DragTransition {
+ switch (event.type) {
+ case "pointer-down":
+ // A press always restarts (a pointerdown while dragging is a new gesture).
+ return {
+ phase: {
+ kind: "pressing",
+ id: event.id,
+ x: event.x,
+ y: event.y,
+ touch: event.touch,
+ },
+ };
+ case "pointer-move": {
+ if (phase.kind === "pressing") {
+ const travelled = Math.hypot(event.x - phase.x, event.y - phase.y);
+ if (travelled < options.dragThresholdPx) return { phase };
+ // Touch: a big movement before the long-press lift is a SCROLL, not a
+ // drag — abandon the press so the canvas scrolls natively.
+ if (phase.touch) return { phase: IDLE };
+ return {
+ phase: {
+ kind: "dragging",
+ id: phase.id,
+ x: event.x,
+ y: event.y,
+ targetIndex: event.targetIndex,
+ touch: phase.touch,
+ },
+ };
+ }
+ if (phase.kind === "dragging") {
+ return {
+ phase: {
+ ...phase,
+ x: event.x,
+ y: event.y,
+ targetIndex: event.targetIndex,
+ },
+ };
+ }
+ return { phase };
+ }
+ case "pointer-up": {
+ if (phase.kind === "pressing") {
+ // No drag, no lift → a tap.
+ return { phase: IDLE, emit: { kind: "tap", id: phase.id } };
+ }
+ if (phase.kind === "dragging") {
+ return {
+ phase: IDLE,
+ emit: { kind: "drop", id: phase.id, targetIndex: phase.targetIndex },
+ };
+ }
+ return { phase };
+ }
+ case "long-press": {
+ if (phase.kind === "pressing" && phase.touch) {
+ // Touch lift: arm the drag at the press point (the pointer will move
+ // from here) + tell the component to select the tile.
+ return {
+ phase: {
+ kind: "dragging",
+ id: phase.id,
+ x: phase.x,
+ y: phase.y,
+ targetIndex: -1,
+ touch: true,
+ },
+ emit: { kind: "lift", id: phase.id },
+ };
+ }
+ return { phase };
+ }
+ case "cancel":
+ return { phase: IDLE };
+ }
+}
diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts
new file mode 100644
index 0000000..f2fbc5b
--- /dev/null
+++ b/src/features/workspaces/logic/tile-layout.test.ts
@@ -0,0 +1,440 @@
+import { describe, expect, it } from "vitest";
+import {
+ clampSpan,
+ fillOrderKey,
+ firstEmptySlot,
+ type GridGeometry,
+ geometryFor,
+ insertAtFirstEmptySlot,
+ mergeLayout,
+ moveTile,
+ pack,
+ resizeTile,
+ sanitizeLayout,
+ sortByRecentActivity,
+ type TileLayoutEntry,
+} from "./tile-layout";
+
+const WIDE = geometryFor(1920, 1080);
+const TALL = geometryFor(390, 844);
+
+function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry {
+ return { id, order, colSpan, rowSpan };
+}
+
+describe("geometryFor", () => {
+ it("resolves landscape/desktop viewports to the WIDE geometry", () => {
+ const geometry = geometryFor(1920, 1080);
+ expect(geometry.mode).toBe("wide");
+ expect(geometry.flowRows).toBe(4);
+ expect(geometry.flowCols).toBe(0);
+ expect(geometry.maxColSpan).toBe(4);
+ expect(geometry.maxRowSpan).toBe(2);
+ expect(geometry.unit.height).toBeGreaterThan(0);
+ expect(geometry.unit.width).toBeGreaterThan(0);
+ });
+
+ it("resolves portrait/mobile viewports to the TALL geometry", () => {
+ const geometry = geometryFor(390, 844);
+ expect(geometry.mode).toBe("tall");
+ expect(geometry.flowCols).toBe(2);
+ expect(geometry.flowRows).toBe(0);
+ expect(geometry.maxColSpan).toBe(2);
+ expect(geometry.maxRowSpan).toBe(2);
+ });
+
+ it("picks the picker dimensions from the span caps (4×2 wide, 2×2 tall)", () => {
+ // The resize picker IS the span cap: cols × rows of small squares.
+ expect([WIDE.maxColSpan, WIDE.maxRowSpan]).toEqual([4, 2]);
+ expect([TALL.maxColSpan, TALL.maxRowSpan]).toEqual([2, 2]);
+ });
+
+ it("resolves degenerate (unmeasured) sizes to wide", () => {
+ expect(geometryFor(0, 0).mode).toBe("wide");
+ });
+});
+
+describe("clampSpan", () => {
+ it("clamps spans to the geometry caps", () => {
+ expect(clampSpan({ colSpan: 4, rowSpan: 2 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 });
+ expect(clampSpan({ colSpan: 9, rowSpan: 9 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 });
+ expect(clampSpan({ colSpan: 3, rowSpan: 1 }, TALL)).toEqual({ colSpan: 2, rowSpan: 1 });
+ });
+
+ it("coerces garbage spans to 1×1", () => {
+ expect(clampSpan({ colSpan: 0, rowSpan: -2 }, WIDE)).toEqual({ colSpan: 1, rowSpan: 1 });
+ expect(clampSpan({ colSpan: Number.NaN, rowSpan: 1.9 }, WIDE)).toEqual({
+ colSpan: 1,
+ rowSpan: 1,
+ });
+ });
+});
+
+describe("pack", () => {
+ it("places tiles in order at the first fitting slot (wide, column-wise flow)", () => {
+ const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], WIDE);
+ expect(placements).toEqual([
+ { 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 },
+ ]);
+ });
+
+ it("flows to the next column when the current one is full (wide)", () => {
+ const placements = pack(
+ [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3), tile("e", 4)],
+ WIDE,
+ );
+ expect(placements.map((p) => [p.col, p.row])).toEqual([
+ [1, 1],
+ [1, 2],
+ [1, 3],
+ [1, 4],
+ [2, 1],
+ ]);
+ });
+
+ it("respects the persisted order over ids", () => {
+ const placements = pack([tile("z", 0), tile("a", 1)], WIDE);
+ expect(placements.map((p) => p.id)).toEqual(["z", "a"]);
+ });
+
+ it("places a double-height tile at the first slot with room (wide)", () => {
+ const placements = pack([tile("a", 0, 1, 2), tile("b", 1), tile("c", 2)], WIDE);
+ expect(placements.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([
+ ["a", 1, 1, 2],
+ ["b", 1, 3, 1],
+ ["c", 1, 4, 1],
+ ]);
+ });
+
+ it("a tile too tall for the remaining rows moves to the next column (wide)", () => {
+ // a,b,c fill rows 1-3; a 2-row tile does not fit the single remaining row → col 2.
+ const placements = pack(
+ [tile("a", 0), tile("b", 1), tile("c", 2), tile("tall", 3, 1, 2)],
+ WIDE,
+ );
+ expect(placements[3]).toEqual({ id: "tall", col: 2, row: 1, colSpan: 1, rowSpan: 2 });
+ });
+
+ it("flows row-wise across two fixed columns in tall mode", () => {
+ const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2)], TALL);
+ expect(placements.map((p) => [p.col, p.row])).toEqual([
+ [1, 1],
+ [2, 1],
+ [1, 2],
+ ]);
+ });
+
+ it("clamps spans per geometry when packing (persisted spans stay raw)", () => {
+ const tiles = [tile("wide", 0, 3, 1), tile("b", 1, 4, 2)];
+ const placements = pack(tiles, TALL);
+ expect(placements).toEqual([
+ { id: "wide", col: 1, row: 1, colSpan: 2, rowSpan: 1 },
+ { id: "b", col: 1, row: 2, colSpan: 2, rowSpan: 2 },
+ ]);
+ // The input is untouched.
+ expect(tiles[0]).toEqual({ id: "wide", order: 0, colSpan: 3, rowSpan: 1 });
+ });
+
+ it("never overlaps tiles", () => {
+ const tiles = [
+ tile("a", 0, 2, 2),
+ tile("b", 1, 2, 2),
+ tile("c", 2, 4, 1),
+ tile("d", 3),
+ tile("e", 4, 1, 2),
+ tile("f", 5),
+ ];
+ const placements = pack(tiles, WIDE);
+ const seen = new Set<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++) {
+ const key = `${r}:${c}`;
+ expect(seen.has(key)).toBe(false);
+ seen.add(key);
+ }
+ }
+ }
+ });
+});
+
+describe("fillOrderKey", () => {
+ it("ranks wide slots column-wise (top-to-bottom, then next column)", () => {
+ expect(fillOrderKey(1, 1, WIDE)).toBeLessThan(fillOrderKey(1, 2, WIDE));
+ expect(fillOrderKey(1, 4, WIDE)).toBeLessThan(fillOrderKey(2, 1, WIDE));
+ });
+
+ it("ranks tall slots row-wise (left-to-right, then next row)", () => {
+ expect(fillOrderKey(1, 1, TALL)).toBeLessThan(fillOrderKey(2, 1, TALL));
+ expect(fillOrderKey(2, 1, TALL)).toBeLessThan(fillOrderKey(1, 2, TALL));
+ });
+});
+
+describe("firstEmptySlot", () => {
+ it("finds the first fitting empty slot in fill order", () => {
+ // Wide, rows 1-2 of col 1 occupied by a double-height tile → first empty is (col1,row3).
+ const tiles = [tile("a", 0, 1, 2)];
+ expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 3 });
+ });
+
+ it("skips slots too small for the requested footprint", () => {
+ // (col1,row4) is the first empty 1×1 slot after three singles, but a
+ // 2-row tile needs rows 4-5 of a 4-row grid — impossible → next column.
+ const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)];
+ expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 4 });
+ expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 2 }, WIDE)).toEqual({ col: 2, row: 1 });
+ });
+
+ it("returns the origin for an empty layout", () => {
+ expect(firstEmptySlot([], { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 1 });
+ });
+
+ it("lands in a brand-new column/row beyond the occupied extent", () => {
+ // Two 4×2 tiles fill columns 1-4 across all 4 rows → the next slot is col 5.
+ const tiles = [tile("a", 0, 4, 2), tile("b", 1, 4, 2)];
+ expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 5, row: 1 });
+ });
+});
+
+describe("insertAtFirstEmptySlot", () => {
+ it("inserts a new tile so it packs into the first empty slot", () => {
+ // 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.
+ 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 placements = pack(next, WIDE);
+ expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["big", 1, 1],
+ ["c", 1, 3],
+ ["new", 1, 4],
+ ]);
+ });
+
+ 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("moves 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);
+ expect(second).toEqual(first);
+ expect(second.filter((t) => t.id === "x")).toHaveLength(1);
+ });
+
+ it("appends when the empty slot is beyond the current extent", () => {
+ 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 });
+ });
+
+ it("preserves the spans of a moved existing tile", () => {
+ const tiles = [tile("x", 0, 2, 2), tile("a", 1), tile("b", 2), tile("c", 3)];
+ const next = insertAtFirstEmptySlot(tiles, "x", WIDE);
+ expect(next.find((t) => t.id === "x")).toMatchObject({ colSpan: 2, rowSpan: 2 });
+ });
+});
+
+describe("resizeTile", () => {
+ it("sets the persisted spans of one tile", () => {
+ const next = resizeTile([tile("a", 0), tile("b", 1)], "b", 4, 2);
+ expect(next.find((t) => t.id === "b")).toMatchObject({ colSpan: 4, rowSpan: 2 });
+ expect(next.find((t) => t.id === "a")).toMatchObject({ colSpan: 1, rowSpan: 1 });
+ });
+
+ it("sanitizes garbage spans to 1 and preserves order", () => {
+ const next = resizeTile([tile("a", 5, 2, 2)], "a", Number.NaN, -3);
+ expect(next).toEqual([{ id: "a", order: 5, colSpan: 1, rowSpan: 1 }]);
+ });
+
+ it("ignores unknown ids", () => {
+ const tiles = [tile("a", 0)];
+ expect(resizeTile(tiles, "nope", 2, 2)).toEqual(tiles);
+ });
+
+ it("does NOT clamp to the current geometry (render-time concern)", () => {
+ // A 4-wide desktop tile resized while wide stays 4-wide in the document;
+ // tall rendering clamps its VIEW.
+ const next = resizeTile([tile("a", 0)], "a", 4, 2);
+ expect(next[0]).toMatchObject({ colSpan: 4, rowSpan: 2 });
+ });
+});
+
+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"]);
+ });
+
+ 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("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("is a no-op for the same index or an unknown id", () => {
+ const tiles = [tile("a", 0), tile("b", 1)];
+ expect(moveTile(tiles, "a", 0)).toEqual(tiles);
+ expect(moveTile(tiles, "x", 1)).toEqual(tiles);
+ });
+
+ 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"]);
+ });
+});
+
+describe("sortByRecentActivity", () => {
+ it("re-applies the recent-activity order (descending)", () => {
+ const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)];
+ const next = sortByRecentActivity(
+ tiles,
+ new Map([
+ ["a", 100],
+ ["b", 300],
+ ["c", 200],
+ ]),
+ );
+ expect(next.map((t) => t.id)).toEqual(["b", "c", "a"]);
+ });
+
+ it("treats unknown ids as least-recent and renumbers", () => {
+ const tiles = [tile("a", 0), tile("b", 1)];
+ // `a` has recent activity (50); `b` is unknown → least-recent → last.
+ const next = sortByRecentActivity(tiles, new Map([["a", 50]]));
+ expect(next.map((t) => [t.id, t.order])).toEqual([
+ ["a", 0],
+ ["b", 1],
+ ]);
+ });
+});
+
+describe("mergeLayout", () => {
+ it("starts workspaces unknown to the layout (appends after the max order)", () => {
+ const merged = mergeLayout({ version: 1, tiles: [tile("a", 0)] }, ["a", "b", "c"]);
+ expect(merged.map((t) => [t.id, t.order])).toEqual([
+ ["a", 0],
+ ["b", 1],
+ ["c", 2],
+ ]);
+ });
+
+ it("appends unknowns in the live list's order", () => {
+ const merged = mergeLayout({ version: 1, tiles: [tile("z", 0)] }, ["z", "y", "x"]);
+ expect(merged.map((t) => t.id)).toEqual(["z", "y", "x"]);
+ });
+
+ it("drops entries for deleted workspaces", () => {
+ const merged = mergeLayout(
+ { version: 1, tiles: [tile("a", 0), tile("gone", 1), tile("b", 2)] },
+ ["a", "b"],
+ );
+ expect(merged.map((t) => t.id)).toEqual(["a", "b"]);
+ });
+
+ it("keeps manual order (not the list order) for known workspaces", () => {
+ const merged = mergeLayout({ version: 1, tiles: [tile("c", 0), tile("a", 1)] }, ["a", "c"]);
+ expect(merged.map((t) => t.id)).toEqual(["c", "a"]);
+ });
+
+ it("clamps spans in the view when a geometry is given (persisted data unchanged)", () => {
+ const merged = mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"], TALL);
+ expect(merged[0]).toMatchObject({ colSpan: 2, rowSpan: 1 });
+ expect(mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"])).toEqual([
+ { id: "a", order: 0, colSpan: 3, rowSpan: 1 },
+ ]);
+ });
+
+ it("deduplicates corrupted duplicate ids (first by order wins)", () => {
+ const merged = mergeLayout({ version: 1, tiles: [tile("a", 1, 2, 2), tile("a", 0)] }, [
+ "a",
+ "b",
+ ]);
+ expect(merged).toEqual([
+ { id: "a", order: 0, colSpan: 1, rowSpan: 1 },
+ { id: "b", order: 1, colSpan: 1, rowSpan: 1 },
+ ]);
+ });
+
+ it("handles a null/undefined persisted document", () => {
+ expect(mergeLayout(null, ["a"])).toEqual([{ id: "a", colSpan: 1, rowSpan: 1, order: 1 }]);
+ expect(mergeLayout(undefined, [])).toEqual([]);
+ });
+});
+
+describe("sanitizeLayout", () => {
+ it("accepts a valid document", () => {
+ expect(
+ sanitizeLayout({ version: 1, tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }] }),
+ ).toEqual({
+ version: 1,
+ 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: [] });
+ });
+
+ it("coerces malformed entries and drops anonymous/duplicate ones", () => {
+ const data = sanitizeLayout({
+ version: 1,
+ tiles: [
+ { id: "a", colSpan: "x", rowSpan: 0, order: "y" },
+ null,
+ { colSpan: 2 },
+ { id: "a", order: 9 },
+ { id: "b", colSpan: 3.9, order: 2.5 },
+ ],
+ });
+ expect(data.tiles).toEqual([
+ { id: "a", colSpan: 1, rowSpan: 1, order: 0 },
+ { id: "b", colSpan: 3, rowSpan: 1, order: 2 },
+ ]);
+ });
+});
+
+describe("geometry-agnostic persistence", () => {
+ it("the SAME persisted data renders in either geometry (positions re-derived, never stored)", () => {
+ const tiles: TileLayoutEntry[] = [
+ tile("a", 0, 4, 2),
+ tile("b", 1),
+ tile("c", 2),
+ tile("d", 3, 2, 2),
+ tile("e", 4),
+ ];
+ const widePlacements = pack(tiles, WIDE);
+ const tallPlacements = pack(tiles, TALL);
+ // Wide flows column-wise across 4 rows; tall flows row-wise across 2 columns.
+ 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.
+ expect(Object.keys(tiles[0] ?? {}).sort()).toEqual(["colSpan", "id", "order", "rowSpan"]);
+ });
+});
+
+describe("type surface", () => {
+ it("exposes the geometry fields the picker and grid need", () => {
+ const geometry: GridGeometry = geometryFor(1000, 500);
+ expect([geometry.mode, geometry.flowRows, geometry.flowCols, geometry.unit.gap]).toEqual([
+ "wide",
+ 4,
+ 0,
+ 8,
+ ]);
+ });
+});
diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts
new file mode 100644
index 0000000..17923c0
--- /dev/null
+++ b/src/features/workspaces/logic/tile-layout.ts
@@ -0,0 +1,427 @@
+/**
+ * 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).
+ *
+ * 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.
+ */
+
+/** The grid geometry mode: landscape/desktop ("wide") or portrait/mobile ("tall"). */
+export type TileGeometryMode = "wide" | "tall";
+
+/** The px size of one tile unit (one grid cell) + the grid gap. */
+export interface TileUnitSize {
+ /**
+ * The unit's px width. `0` means "fluid" (the tall grid sizes its fixed
+ * columns in `1fr` so two tiles fill a phone's width).
+ */
+ readonly width: number;
+ readonly height: number;
+ readonly gap: number;
+}
+
+/**
+ * The full grid geometry a viewport resolves to. `flowRows`/`flowCols` name
+ * the FIXED track counts across the fill direction's cross axis:
+ * wide mode fixes ROWS (tiles flow column-wise, columns grow unbounded);
+ * tall mode fixes COLS (tiles flow row-wise, rows grow unbounded). `0` =
+ * unbounded. The picker (resize) dimensions equal the span caps.
+ */
+export interface GridGeometry {
+ readonly mode: TileGeometryMode;
+ /** Fixed row tracks (wide: 4; tall: 0 = rows grow unbounded). */
+ readonly flowRows: number;
+ /** Fixed column tracks (tall: 2; wide: 0 = columns grow unbounded). */
+ readonly flowCols: number;
+ /** Maximum horizontal span a tile may occupy in this geometry. */
+ readonly maxColSpan: number;
+ /** Maximum vertical span a tile may occupy in this geometry. */
+ readonly maxRowSpan: number;
+ readonly unit: TileUnitSize;
+}
+
+/** One persisted tile: which workspace it pins, how big it wants to be, and its manual position in the order. */
+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. */
+ readonly order: number;
+}
+
+/** The persisted document (localStorage via the injected `LocalStore` adapter). */
+export interface TileLayoutData {
+ readonly version: 1;
+ readonly tiles: readonly TileLayoutEntry[];
+}
+
+/** A tile's resolved footprint on the grid (1-based grid coordinates). */
+export interface Placement {
+ readonly id: string;
+ readonly col: number;
+ readonly row: number;
+ readonly colSpan: number;
+ readonly rowSpan: number;
+}
+
+/** A requested span (pre-clamping). */
+export interface Span {
+ readonly colSpan: number;
+ readonly rowSpan: number;
+}
+
+/** The viewport assumed before the canvas has been measured (a landscape desktop). */
+export const DEFAULT_VIEWPORT = { width: 1280, height: 800 } as const;
+
+const WIDE_UNIT: TileUnitSize = { width: 128, height: 120, gap: 8 };
+const TALL_UNIT: TileUnitSize = { width: 0, height: 120, gap: 8 };
+
+/**
+ * Resolve the grid geometry from the tile canvas' size. Landscape/desktop →
+ * WIDE (4 fixed ~120px rows, column-wise flow, 4×2 span cap); portrait/mobile
+ * → TALL (2 fixed columns, row-wise flow, 2×2 span cap). Degenerate sizes
+ * (not yet measured) resolve to wide. Pure: numbers in, geometry out.
+ */
+export function geometryFor(width: number, height: number): GridGeometry {
+ const tall = width > 0 && height > 0 && height > width;
+ if (tall) {
+ return {
+ mode: "tall",
+ flowRows: 0,
+ flowCols: 2,
+ maxColSpan: 2,
+ maxRowSpan: 2,
+ unit: TALL_UNIT,
+ };
+ }
+ return {
+ mode: "wide",
+ flowRows: 4,
+ flowCols: 0,
+ maxColSpan: 4,
+ maxRowSpan: 2,
+ unit: WIDE_UNIT,
+ };
+}
+
+/** Sort entries by their persisted order (stable — ties keep their relative order). */
+export function sortTiles<T extends TileLayoutEntry>(tiles: readonly T[]): T[] {
+ return [...tiles].sort((a, b) => a.order - b.order);
+}
+
+/** Clamp a requested span to a geometry's caps, coercing garbage (non-positive/NaN) to 1. */
+export function clampSpan<T extends Span>(span: T, geometry: GridGeometry): Span {
+ return {
+ colSpan: clampUnit(span.colSpan, geometry.maxColSpan),
+ rowSpan: clampUnit(span.rowSpan, geometry.maxRowSpan),
+ };
+}
+
+function clampUnit(value: number, max: number): number {
+ if (!Number.isFinite(value)) return 1;
+ const int = Math.floor(value);
+ if (int < 1) return 1;
+ return Math.min(int, max);
+}
+
+/**
+ * A position's rank in the geometry's FILL ORDER — the order slots are visited
+ * when packing: wide fills column-wise (top-to-bottom, then the next column);
+ * tall fills row-wise (left-to-right, then the next row). Pure; only used for
+ * comparisons within one geometry.
+ */
+export function fillOrderKey(col: number, row: number, geometry: GridGeometry): number {
+ if (geometry.mode === "wide") return (col - 1) * geometry.flowRows + (row - 1);
+ return (row - 1) * geometry.flowCols + (col - 1);
+}
+
+function cellsFree(
+ occupied: ReadonlySet<string>,
+ col: number,
+ row: number,
+ colSpan: number,
+ rowSpan: number,
+): boolean {
+ for (let r = row; r < row + rowSpan; r++) {
+ for (let c = col; c < col + colSpan; c++) {
+ if (occupied.has(`${r}:${c}`)) return false;
+ }
+ }
+ return true;
+}
+
+function occupy(
+ occupied: Set<string>,
+ col: number,
+ row: number,
+ colSpan: number,
+ rowSpan: number,
+): void {
+ for (let r = row; r < row + rowSpan; r++) {
+ for (let c = col; c < col + colSpan; c++) {
+ occupied.add(`${r}:${c}`);
+ }
+ }
+}
+
+function maxOccupied(occupied: ReadonlySet<string>, axis: "col" | "row"): number {
+ let max = 0;
+ for (const cell of occupied) {
+ const value = Number.parseInt(
+ axis === "row" ? (cell.split(":")[0] ?? "") : (cell.split(":")[1] ?? ""),
+ 10,
+ );
+ if (Number.isFinite(value) && value > max) max = value;
+ }
+ return max;
+}
+
+/**
+ * The first slot (in fill order) where a span-sized footprint fits without
+ * overlapping or exceeding the geometry's fixed cross-axis. Beyond the
+ * occupied region every cell is free, so the scan is bounded by the occupied
+ * extent — the result always exists (the flow is unbounded along its main
+ * axis). Pure: occupancy + span + geometry in, slot out.
+ */
+function firstFit(
+ occupied: ReadonlySet<string>,
+ span: Span,
+ geometry: GridGeometry,
+): { col: number; row: number } {
+ const { colSpan, rowSpan } = clampSpan(span, geometry);
+ if (geometry.mode === "wide") {
+ const maxCol = maxOccupied(occupied, "col");
+ for (let col = 1; col <= maxCol; col++) {
+ for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) {
+ if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
+ }
+ }
+ return { col: maxCol + 1, row: 1 };
+ }
+ const maxRow = maxOccupied(occupied, "row");
+ for (let row = 1; row <= maxRow; row++) {
+ for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) {
+ if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
+ }
+ }
+ return { col: 1, row: maxRow + 1 };
+}
+
+/**
+ * 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).
+ */
+export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] {
+ const occupied = new Set<string>();
+ const placements: Placement[] = [];
+ for (const tile of sortTiles(tiles)) {
+ 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 });
+ }
+ return placements;
+}
+
+/**
+ * The first footprint-fitting EMPTY slot in fill order — where a NEW tile of
+ * the given span would go (the header's create flow). Pure: entries + span +
+ * geometry in, a slot placement out (always exists; may be a new column/row
+ * beyond the current extent).
+ */
+export function firstEmptySlot(
+ tiles: readonly TileLayoutEntry[],
+ span: Span,
+ geometry: GridGeometry,
+): { col: number; row: number } {
+ return firstFit(packOccupancy(tiles, geometry), span, geometry);
+}
+
+/** Build the occupancy set of the tiles' packed positions. */
+function packOccupancy(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Set<string> {
+ const occupied = new Set<string>();
+ for (const placement of pack(tiles, geometry)) {
+ occupy(occupied, placement.col, placement.row, placement.colSpan, placement.rowSpan);
+ }
+ return occupied;
+}
+
+/** Re-assign orders 0..n-1 by array position (the canonical order encoding after structural edits). */
+function renumber(tiles: readonly TileLayoutEntry[]): TileLayoutEntry[] {
+ return tiles.map((tile, index) => (tile.order === index ? tile : { ...tile, order: index }));
+}
+
+/**
+ * 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).
+ */
+export function insertAtFirstEmptySlot(
+ 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 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)]);
+}
+
+/**
+ * Resize one tile: set its persisted spans (sanitized to positive integers —
+ * NOT clamped to the current geometry; clamping is a render-time concern so a
+ * wide-sized tile survives an orientation switch). Unknown ids are ignored.
+ * Pure: entries in, a NEW entries array out.
+ */
+export function resizeTile(
+ tiles: readonly TileLayoutEntry[],
+ id: string,
+ colSpan: number,
+ rowSpan: number,
+): TileLayoutEntry[] {
+ const sanitized: Span = {
+ colSpan: clampUnit(colSpan, Number.MAX_SAFE_INTEGER),
+ rowSpan: clampUnit(rowSpan, Number.MAX_SAFE_INTEGER),
+ };
+ return sortTiles(tiles).map((tile) =>
+ tile.id === id ? { ...tile, colSpan: sanitized.colSpan, rowSpan: sanitized.rowSpan } : tile,
+ );
+}
+
+/**
+ * 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.
+ */
+export function sortByRecentActivity(
+ tiles: readonly TileLayoutEntry[],
+ lastActivityById: ReadonlyMap<string, number>,
+): TileLayoutEntry[] {
+ const sorted = sortTiles(tiles).sort(
+ (a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0),
+ );
+ return renumber(sorted);
+}
+
+/**
+ * Merge the persisted layout with the live workspace list: workspaces unknown
+ * to the layout (e.g. auto-created via deep-link) APPEND at the end (default
+ * 1×1, order after the current max); entries for deleted workspaces DROP;
+ * duplicate ids dedup (first wins); the result sorts by order. When a
+ * geometry is given, the returned VIEW clamps spans to it (the persisted
+ * document keeps the raw spans — clamping is transient per geometry). Pure:
+ * both inputs in, a NEW entries array out.
+ */
+export function mergeLayout(
+ persisted: TileLayoutData | null | undefined,
+ workspaceIds: readonly string[],
+ geometry?: GridGeometry,
+): TileLayoutEntry[] {
+ const known = new Set(workspaceIds);
+ const kept: TileLayoutEntry[] = [];
+ const seen = new Set<string>();
+ let maxOrder = 0;
+ for (const tile of sortTiles(persisted?.tiles ?? [])) {
+ if (!known.has(tile.id) || seen.has(tile.id)) continue;
+ seen.add(tile.id);
+ kept.push(tile);
+ if (tile.order > maxOrder) maxOrder = tile.order;
+ }
+ const appended: TileLayoutEntry[] = [];
+ for (const id of workspaceIds) {
+ if (seen.has(id)) continue;
+ seen.add(id);
+ maxOrder += 1;
+ appended.push({ id, colSpan: 1, rowSpan: 1, order: maxOrder });
+ }
+ const merged = [...kept, ...appended];
+ if (!geometry) return merged;
+ return merged.map((tile) => ({ ...tile, ...clampSpan(tile, geometry) }));
+}
+
+/**
+ * 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.
+ */
+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;
+ const rawTiles = (raw as { tiles?: unknown }).tiles;
+ if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT;
+ const tiles: TileLayoutEntry[] = [];
+ const seen = new Set<string>();
+ for (let index = 0; index < rawTiles.length; index++) {
+ const rawTile = rawTiles[index];
+ if (typeof rawTile !== "object" || rawTile === null) continue;
+ const id = (rawTile as { id?: unknown }).id;
+ if (typeof id !== "string" || id === "" || seen.has(id)) continue;
+ seen.add(id);
+ 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),
+ });
+ }
+ return { version: 1, tiles };
+}
+
+const EMPTY_LAYOUT: TileLayoutData = { version: 1, tiles: [] };
+
+function coerceSpan(value: unknown): number {
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1;
+ return Math.floor(value);
+}
+
+function coerceOrder(value: unknown, fallback: number): number {
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
+ return Math.floor(value);
+}