summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/logic
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/workspaces/logic')
-rw-r--r--src/features/workspaces/logic/create.test.ts50
-rw-r--r--src/features/workspaces/logic/create.ts64
-rw-r--r--src/features/workspaces/logic/drag-controller.test.ts180
-rw-r--r--src/features/workspaces/logic/drag-controller.ts162
-rw-r--r--src/features/workspaces/logic/tile-layout.test.ts857
-rw-r--r--src/features/workspaces/logic/tile-layout.ts709
-rw-r--r--src/features/workspaces/logic/view-model.test.ts98
-rw-r--r--src/features/workspaces/logic/view-model.ts58
8 files changed, 2167 insertions, 11 deletions
diff --git a/src/features/workspaces/logic/create.test.ts b/src/features/workspaces/logic/create.test.ts
new file mode 100644
index 0000000..278bcd6
--- /dev/null
+++ b/src/features/workspaces/logic/create.test.ts
@@ -0,0 +1,50 @@
+import type { Workspace } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import {
+ archivedCreateMessage,
+ checkFailedCreateMessage,
+ gateCreate,
+ type WorkspaceLookup,
+} from "./create";
+
+function workspace(overrides: Partial<Workspace> = {}): Workspace {
+ return {
+ id: "old",
+ title: "Old",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ ...overrides,
+ };
+}
+
+describe("gateCreate", () => {
+ it("blocks a slug whose workspace is archived, with the can't-create message", () => {
+ const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: true }) };
+ const gate = gateCreate("old", lookup);
+ expect(gate).toEqual({ ok: false, error: archivedCreateMessage("old") });
+ expect(gate.ok === false && gate.error).toContain(
+ 'A workspace named "old" already exists and is archived — you can\'t create it.',
+ );
+ });
+
+ it("proceeds for an existing ACTIVE workspace (ensure is idempotent)", () => {
+ const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: false }) };
+ expect(gateCreate("old", lookup)).toEqual({ ok: true });
+ });
+
+ it("proceeds for a missing slug", () => {
+ expect(gateCreate("fresh", { kind: "missing" })).toEqual({ ok: true });
+ });
+
+ it("blocks when the existence probe failed (never guesses)", () => {
+ const gate = gateCreate("maybe", { kind: "failed", error: "db down" });
+ expect(gate).toEqual({ ok: false, error: checkFailedCreateMessage("maybe", "db down") });
+ expect(gate.ok === false && gate.error).toContain(
+ 'Couldn\'t check whether a workspace named "maybe"',
+ );
+ });
+});
diff --git a/src/features/workspaces/logic/create.ts b/src/features/workspaces/logic/create.ts
new file mode 100644
index 0000000..c9385e1
--- /dev/null
+++ b/src/features/workspaces/logic/create.ts
@@ -0,0 +1,64 @@
+/**
+ * Pure create-gate logic for the New-workspace form — zero DOM, zero effects.
+ *
+ * `PUT /workspaces/:id` (ensure) is idempotent, so creating a slug that belongs
+ * to an ARCHIVED workspace would silently "succeed": the backend returns the
+ * archived workspace, the active list (which excludes archived) never shows it,
+ * and the home grid would still place a tile for something invisible. The
+ * frontend therefore probes `GET /workspaces/:id` first and refuses the create
+ * when the slug exists and is archived.
+ */
+
+import type { Workspace } from "@dispatch/wire";
+
+/**
+ * The authoritative existence probe's outcome (`GET /workspaces/:id`). The
+ * adapter maps HTTP status to these kinds; `failed` is deliberately distinct
+ * from `missing` (404) so a broken check is never mistaken for "no such
+ * workspace".
+ */
+export type WorkspaceLookup =
+ | { readonly kind: "found"; readonly workspace: Workspace }
+ | { readonly kind: "missing" }
+ | { readonly kind: "failed"; readonly error: string };
+
+/** Whether a create may proceed: `ok` or a user-facing error message. */
+export type CreateGate = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
+/**
+ * The user-facing message when a slug belongs to an archived workspace. It
+ * states plainly that the create can't happen and points at the existing
+ * recovery affordance (the Archived workspaces list) without adding UI.
+ */
+export function archivedCreateMessage(id: string): string {
+ return `A workspace named "${id}" already exists and is archived — you can't create it. Unarchive it from Archived workspaces instead.`;
+}
+
+/**
+ * The user-facing message when the existence probe itself failed. Proceeding
+ * blindly could recreate the silent archived-tile bug, so the create is
+ * refused with a retryable message instead.
+ */
+export function checkFailedCreateMessage(id: string, error: string): string {
+ return `Couldn't check whether a workspace named "${id}" already exists (${error}). Try again.`;
+}
+
+/**
+ * Decide whether a create may proceed, given the probe outcome. Pure:
+ * `(id, lookup) → gate`.
+ *
+ * - archived slug → blocked with the "can't create" message;
+ * - missing slug or an ACTIVE existing slug → proceed (`ensure` is idempotent,
+ * so an active slug keeps today's behavior);
+ * - a failed probe → blocked too (plan §3.4): never guess, because proceeding
+ * could ensure an archived workspace's tile.
+ */
+export function gateCreate(id: string, lookup: WorkspaceLookup): CreateGate {
+ if (lookup.kind === "failed") {
+ return { ok: false, error: checkFailedCreateMessage(id, lookup.error) };
+ }
+ if (lookup.kind === "found" && lookup.workspace.archived) {
+ return { ok: false, error: archivedCreateMessage(id) };
+ }
+ return { ok: true };
+}
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..459063a
--- /dev/null
+++ b/src/features/workspaces/logic/drag-controller.test.ts
@@ -0,0 +1,180 @@
+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, targetCell: { col: 4, row: 1 } },
+ OPTS,
+ );
+ expect(transition.phase).toEqual({
+ kind: "dragging",
+ id: "t1",
+ x: 20,
+ y: 12,
+ targetCell: { col: 4, row: 1 },
+ touch: false,
+ });
+ expect(transition.emit).toBeUndefined();
+ });
+
+ it("movement within the threshold stays pressing", () => {
+ const transition = nextDragPhase(
+ press(),
+ { 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 });
+ });
+
+ 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, targetCell: { col: 3, row: 1 } },
+ OPTS,
+ );
+ transition = nextDragPhase(
+ transition.phase,
+ { 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", targetCell: { col: 6, row: 1 } });
+ 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,
+ targetCell: null,
+ 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, targetCell: { col: 1, row: 1 } },
+ 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, targetCell: { col: 1, row: 1 } },
+ 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, targetCell: { col: 5, row: 1 } },
+ OPTS,
+ );
+ expect(transition.phase).toEqual({
+ kind: "dragging",
+ id: "t1",
+ x: 10,
+ y: 90,
+ targetCell: { col: 5, row: 1 },
+ 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", targetCell: null });
+ });
+});
+
+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, targetCell: { col: 2, row: 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, targetCell: { col: 1, row: 1 } },
+ 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, targetCell: { col: 2, row: 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..a1877aa
--- /dev/null
+++ b/src/features/workspaces/logic/drag-controller.ts
@@ -0,0 +1,162 @@
+/**
+ * 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 / place).
+ */
+
+import type { Cell } from "./tile-layout";
+
+/** 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;
+ /** The grid CELL under the pointer (null = not over the canvas yet). */
+ readonly targetCell: Cell | null;
+ 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 targetCell: Cell | null };
+
+/** 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 targetCell: Cell | null;
+ }
+ | { 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,
+ targetCell: event.targetCell,
+ touch: phase.touch,
+ },
+ };
+ }
+ if (phase.kind === "dragging") {
+ return {
+ phase: {
+ ...phase,
+ x: event.x,
+ y: event.y,
+ targetCell: event.targetCell,
+ },
+ };
+ }
+ 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, targetCell: phase.targetCell },
+ };
+ }
+ 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,
+ targetCell: null,
+ 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..b538373
--- /dev/null
+++ b/src/features/workspaces/logic/tile-layout.test.ts
@@ -0,0 +1,857 @@
+import { describe, expect, it } from "vitest";
+import {
+ clampSpan,
+ displacePlace,
+ dragAppendLimit,
+ fillOrderKey,
+ firstEmptySlot,
+ type GridGeometry,
+ geometryFor,
+ mergeLayout,
+ type Placement,
+ pack,
+ pinAtFirstEmpty,
+ placeAtCell,
+ resizeTile,
+ sanitizeLayout,
+ sortByRecentActivity,
+ type TileLayoutEntry,
+ toRenderCell,
+ toUniversalCell,
+ UNIVERSAL_ROWS,
+} 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");
+ });
+
+ it("derives the wide unit from the canvas height so the 4 rows fill it", () => {
+ // 4·unit + 3·gap + 2·margin ≈ height: an 800px canvas gets ~160px tiles,
+ // NOT the old fixed 120px (the "squares are so small" complaint).
+ const geometry = geometryFor(1440, 800);
+ const filled = 4 * geometry.unit.height + 3 * geometry.unit.gap + 2 * geometry.margin;
+ expect(geometry.unit.height).toBeGreaterThanOrEqual(140);
+ expect(Math.abs(filled - 800)).toBeLessThanOrEqual(8);
+ expect(geometry.unit.width).toBe(geometry.unit.height);
+ });
+
+ it("clamps the wide unit to its band on very short and very tall canvases", () => {
+ const short = geometryFor(1200, 300);
+ const huge = geometryFor(5000, 4000); // wide (width ≥ height) with a huge height
+ expect(short.unit.height).toBe(112);
+ expect(huge.unit.height).toBe(184);
+ // The margin never collapses (the "no margins" complaint) nor dominates.
+ expect(short.margin).toBeGreaterThanOrEqual(24);
+ expect(huge.margin).toBeLessThanOrEqual(64);
+ });
+
+ it("always carries a positive margin (the tile field never hugs the edge)", () => {
+ const sizes: [number, number][] = [
+ [1920, 1080],
+ [390, 844],
+ [0, 0],
+ [1200, 300],
+ ];
+ for (const [w, h] of sizes) {
+ expect(geometryFor(w, h).margin).toBeGreaterThan(0);
+ }
+ });
+
+ it("keeps the tall unit fixed with a gap (its columns are fluid)", () => {
+ expect(TALL.unit.width).toBe(0);
+ expect(TALL.unit.height).toBeGreaterThanOrEqual(120);
+ expect(TALL.unit.gap).toBeGreaterThan(8);
+ });
+});
+
+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("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 PINS exactly there.
+ const tiles = [tile("big", 0, 1, 2), tile("c", 1)];
+ 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],
+ ["c", 1, 3],
+ ["new", 1, 4],
+ ]);
+ });
+
+ 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("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 = 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 (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 = pinAtFirstEmpty(tiles, "e", WIDE);
+ expect(next.find((t) => t.id === "e")).toMatchObject({ col: 2, row: 1 });
+ });
+
+ 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 = 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", () => {
+ 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("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. The array
+ // keeps the INPUT render order (only coordinates change).
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["X", 1, 2],
+ ["X2", 1, 3],
+ ["Y", 1, 1],
+ ]);
+ });
+
+ 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);
+ // Same coordinates, input render order: X, Z, then the dragged Y.
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["X", 1, 3],
+ ["Z", 1, 4],
+ ["Y", 1, 2],
+ ]);
+ });
+
+ 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);
+ // The array keeps the input render order (a first) — only a's coords move.
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 1, 3],
+ ["b", 1, 2],
+ ]);
+ });
+
+ 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("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. The array keeps the
+ // INPUT render order (a,b,c,d) — only coordinates change.
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 1, 4],
+ ["b", 1, 2],
+ ["c", 1, 3],
+ ["d", 2, 1],
+ ]);
+ });
+
+ 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("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)];
+ 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("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);
+ });
+});
+
+describe("dragAppendLimit", () => {
+ it("caps a wide drag at 3 columns past the right-most content edge (span-aware)", () => {
+ // b spans cols 3-4 → the right-most occupied column is 4; +3 = 7.
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 3, row: 1, colSpan: 2, rowSpan: 1 },
+ ];
+ expect(dragAppendLimit(placements, WIDE)).toBe(7);
+ });
+
+ it("caps a tall drag at 3 rows past the bottom-most content edge (span-aware)", () => {
+ // b spans rows 3-4 → the bottom-most occupied row is 4; +3 = 7.
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 2 },
+ { id: "b", col: 2, row: 3, colSpan: 1, rowSpan: 2 },
+ ];
+ expect(dragAppendLimit(placements, TALL)).toBe(7);
+ });
+
+ it("an empty grid still allows the first tiles within the first 3 columns", () => {
+ expect(dragAppendLimit([], WIDE)).toBe(3);
+ expect(dragAppendLimit([], TALL)).toBe(3);
+ });
+
+ it("honors a custom extra", () => {
+ const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }];
+ expect(dragAppendLimit(placements, WIDE, 5)).toBe(6);
+ });
+});
+
+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],
+ ]);
+ });
+
+ 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", () => {
+ 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 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: 2,
+ tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }],
+ });
+ });
+
+ it("rejects foreign versions and malformed documents", () => {
+ 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", () => {
+ 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 },
+ ]);
+ });
+
+ 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 (unpinned flow re-derives)", () => {
+ 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 });
+ // 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,
+ // so the gap is 12px and the margin 37px (never the old fixed 8px gap).
+ const geometry: GridGeometry = geometryFor(1000, 500);
+ expect([
+ geometry.mode,
+ geometry.flowRows,
+ geometry.flowCols,
+ geometry.unit.gap,
+ geometry.margin,
+ ]).toEqual(["wide", 4, 0, 12, 37]);
+ });
+});
diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts
new file mode 100644
index 0000000..6f7c281
--- /dev/null
+++ b/src/features/workspaces/logic/tile-layout.ts
@@ -0,0 +1,709 @@
+/**
+ * Pure tile-layout logic for the Win8-Start-style workspace home grid — zero
+ * DOM, zero Svelte, zero effects.
+ *
+ * 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.
+ */
+
+/** 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;
+ /**
+ * The breathing room around the whole tile field — the grid never renders
+ * flush against the canvas edge. In wide mode the scroller pads all four
+ * sides by this amount (the pan arrow/scroll indicator offset inward by it
+ * too); in tall mode it insets the two fluid columns horizontally.
+ */
+ readonly margin: number;
+}
+
+/** 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. 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 | 2;
+ 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;
+
+/**
+ * The wide unit's clamp band (px). Tiles FILL the canvas' height across the 4
+ * fixed rows (the Start-screen feel), but never shrink below a legible small
+ * tile nor grow past a comfortable large one.
+ */
+const WIDE_UNIT_MIN = 112;
+const WIDE_UNIT_MAX = 184;
+
+/** The wide canvas' outer margin band (px) — proportional to the unit size. */
+const WIDE_MARGIN_MIN = 24;
+const WIDE_MARGIN_MAX = 64;
+
+/** The tall unit: the two fluid columns size in `1fr` (width `0` = fluid). */
+const TALL_UNIT: TileUnitSize = { width: 0, height: 148, gap: 12 };
+const TALL_MARGIN = 16;
+
+/** Round to whole pixels (fractional track sizes blur tile edges). */
+function px(value: number): number {
+ return Math.round(value);
+}
+
+function clampNumber(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+/**
+ * Resolve the grid geometry from the tile canvas' size. Landscape/desktop →
+ * WIDE (4 fixed 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.
+ *
+ * The wide unit is DERIVED from the canvas height so the 4 rows exactly fill
+ * it (unit + margin + gap all proportional, clamped to their bands); the tall
+ * unit is fixed (its columns are fluid instead). 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,
+ margin: TALL_MARGIN,
+ };
+ }
+ // Solve 4·unit + 3·gap + 2·margin = height with gap = unit/9 and margin =
+ // unit/3: 4u + u/3 + 2u/3 = 5u → u = height/5. Unmeasured (height ≤ 0) →
+ // the band's middle, not a tiny tile.
+ const raw = height > 0 ? height / 5 : (WIDE_UNIT_MIN + WIDE_UNIT_MAX) / 2;
+ const unit = px(clampNumber(raw, WIDE_UNIT_MIN, WIDE_UNIT_MAX));
+ const gap = Math.max(10, px(unit / 9));
+ const margin = px(clampNumber(unit / 3, WIDE_MARGIN_MIN, WIDE_MARGIN_MAX));
+ return {
+ mode: "wide",
+ flowRows: 4,
+ flowCols: 0,
+ maxColSpan: 4,
+ maxRowSpan: 2,
+ unit: { width: unit, height: unit, gap },
+ margin,
+ };
+}
+
+/** 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);
+}
+
+/**
+ * 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,
+ 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 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 | 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[index] = { id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan };
+ }
+ 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 — in the INPUT array order (only coordinates
+ * change). A keyed `{#each}` over the result therefore never moves DOM nodes;
+ * moving a captured element drops its pointer capture — the "stuck tile" bug.
+ */
+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 in the INPUT render order (coordinates change only): the live drag
+ // preview feeds a keyed `{#each}`, and a reordered array moves the DOM nodes
+ // — a moved captured element silently loses pointer capture (the stuck-tile
+ // bug). The commit path re-sorts to stream order itself (`placeAtCell`).
+ const byId = new Map(placed.map((placement) => [placement.id, placement]));
+ return placements.map((placement) => byId.get(placement.id) ?? placement);
+}
+
+/**
+ * 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);
+ // displacePlace preserves the input RENDER order for the live preview; the
+ // commit pins + renumbers in STREAM order, so re-sort before pinning.
+ const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry)
+ .slice()
+ .sort((a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, 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 }));
+}
+
+/**
+ * The drag target's clamp on the geometry's UNBOUNDED axis — a drop may aim
+ * at most `extra` columns (wide) / rows (tall) past the current content edge,
+ * so a drag can never create an endlessly growing canvas ("hard to place if
+ * it just grows"): wide bound = the right-most occupied column (span-aware) +
+ * `extra`; tall = the bottom-most occupied row + `extra`. Empty content → the
+ * first tiles may land within the first `extra` columns. The bound is measured
+ * on the COMMITTED placements — a drag always compares against the layout it
+ * started from, never the live preview, so the limit cannot ratchet mid-drag.
+ * Pure: placements + geometry in, the max allowed column (wide) / row (tall)
+ * out.
+ */
+export function dragAppendLimit(
+ placements: readonly Placement[],
+ geometry: GridGeometry,
+ extra = 3,
+): number {
+ let extent = 0;
+ for (const placement of placements) {
+ extent = Math.max(
+ extent,
+ geometry.mode === "wide"
+ ? placement.col + placement.colSpan - 1
+ : placement.row + placement.rowSpan - 1,
+ );
+ }
+ return extent + extra;
+}
+
+/**
+ * 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 }));
+}
+
+/**
+ * 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 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 anchor = toUniversalCell(slot, geometry);
+ const existing = tiles.find((tile) => tile.id === id);
+ 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];
+}
+
+/**
+ * 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,
+ );
+}
+
+/**
+ * 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[],
+ 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.map((tile) => {
+ const { col: _col, row: _row, ...unpinned } = tile;
+ return unpinned;
+ }),
+ );
+}
+
+/**
+ * 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). 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 && version !== 2) 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);
+ 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: 2, tiles };
+}
+
+const EMPTY_LAYOUT: TileLayoutData = { version: 2, 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);
+}
+
+/** 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/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts
index 44d2f31..31366ba 100644
--- a/src/features/workspaces/logic/view-model.test.ts
+++ b/src/features/workspaces/logic/view-model.test.ts
@@ -1,6 +1,39 @@
import type { WorkspaceEntry } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
-import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model";
+import {
+ applyArchived,
+ applyStarred,
+ pageTitle,
+ relativeTime,
+ sortWorkspaces,
+ tileTone,
+} from "./view-model";
+
+describe("tileTone", () => {
+ const TONES = ["primary", "secondary", "accent", "info", "success", "warning"];
+
+ it("is deterministic — the same id always gets the same tone", () => {
+ expect(tileTone("my-workspace")).toBe(tileTone("my-workspace"));
+ });
+
+ it("only returns known tones", () => {
+ for (const id of ["default", "a", "my-workspace", "x-1", "project-phoenix", "zzz"]) {
+ expect(TONES).toContain(tileTone(id));
+ }
+ });
+
+ it("spreads distinct ids across more than one tone", () => {
+ const tones = new Set(
+ ["default", "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"].map(tileTone),
+ );
+ expect(tones.size).toBeGreaterThan(1);
+ });
+
+ it("is case-sensitive (ids are slugs, but the hash makes no normalization promise)", () => {
+ // Not a correctness requirement — just documents the hash reads raw chars.
+ expect(typeof tileTone("ABC")).toBe("string");
+ });
+});
describe("relativeTime", () => {
const now = 1_000_000_000_000; // 2001-09-09
@@ -23,10 +56,15 @@ describe("relativeTime", () => {
expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d");
});
- it("is a short date beyond a week", () => {
- // 7+ days ago: just check it is a MM/DD string.
- const s = relativeTime(now - 10 * 24 * 60 * 60_000, now);
- expect(s).toMatch(/^\d{2}\/\d{2}$/);
+ it("keeps days past a week (no short date) up to a month", () => {
+ expect(relativeTime(now - 10 * 24 * 60 * 60_000, now)).toBe("10d");
+ expect(relativeTime(now - 29 * 24 * 60 * 60_000, now)).toBe("29d");
+ });
+
+ it("is months past a month", () => {
+ expect(relativeTime(now - 30 * 24 * 60 * 60_000, now)).toBe("1mo");
+ expect(relativeTime(now - 75 * 24 * 60 * 60_000, now)).toBe("2mo");
+ expect(relativeTime(now - 365 * 24 * 60 * 60_000, now)).toBe("12mo");
});
});
@@ -38,6 +76,7 @@ describe("pageTitle", () => {
defaultCwd: null,
defaultComputerId: null,
starred: false,
+ archived: false,
createdAt: 0,
lastActivityAt: 0,
conversationCount: 0,
@@ -75,6 +114,7 @@ describe("sortWorkspaces", () => {
defaultCwd: null,
defaultComputerId: null,
starred,
+ archived: false,
createdAt: 0,
lastActivityAt,
conversationCount: 0,
@@ -133,6 +173,7 @@ describe("applyStarred", () => {
defaultCwd: null,
defaultComputerId: null,
starred,
+ archived: false,
createdAt: 0,
lastActivityAt: 0,
conversationCount: 0,
@@ -177,3 +218,50 @@ describe("applyStarred", () => {
expect(reverted[0]?.starred).toBe(false);
});
});
+
+describe("applyArchived", () => {
+ const entry = (id: string, archived: boolean): WorkspaceEntry => ({
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived,
+ createdAt: 0,
+ lastActivityAt: 0,
+ conversationCount: 0,
+ });
+
+ it("sets the named workspace's archived flag", () => {
+ const list = [entry("a", false), entry("b", false)];
+ const next = applyArchived(list, "b", true);
+ expect(next.map((w) => [w.id, w.archived])).toEqual([
+ ["a", false],
+ ["b", true],
+ ]);
+ });
+
+ it("returns a new array (does not mutate the input)", () => {
+ const list = [entry("a", false)];
+ const next = applyArchived(list, "a", true);
+ expect(next).not.toBe(list);
+ expect(list[0]?.archived).toBe(false);
+ expect(next[0]?.archived).toBe(true);
+ });
+
+ it("leaves other entries referentially unchanged (only the target is replaced)", () => {
+ const a = entry("a", false);
+ const b = entry("b", false);
+ const next = applyArchived([a, b], "b", true);
+ expect(next[0]).toBe(a);
+ expect(next[1]).not.toBe(b);
+ });
+
+ it("can revert by re-applying the previous value (unarchive)", () => {
+ const list = [entry("a", false)];
+ const optimistic = applyArchived(list, "a", true);
+ expect(optimistic[0]?.archived).toBe(true);
+ const reverted = applyArchived(optimistic, "a", false);
+ expect(reverted[0]?.archived).toBe(false);
+ });
+});
diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts
index b994b7c..cdf99c8 100644
--- a/src/features/workspaces/logic/view-model.ts
+++ b/src/features/workspaces/logic/view-model.ts
@@ -6,6 +6,40 @@ import type { WorkspaceEntry } from "@dispatch/wire";
import type { Route } from "./route";
/**
+ * A home tile's color tone. Windows 8's Start screen gave every tile its own
+ * saturated color; here the tile's workspace id DETERMINISTICALLY picks one of
+ * the theme's six semantic tones, so the same workspace keeps its color across
+ * sessions and themes (the tone name maps to a DaisyUI color, which adapts to
+ * the active theme). Starred stays a separate visual concern (a badge), NOT a
+ * tone — any tile may be starred regardless of its color.
+ */
+export type TileTone = "primary" | "secondary" | "accent" | "info" | "success" | "warning";
+
+/** The tones a tile may take, in hash order. */
+const TILE_TONES: readonly TileTone[] = [
+ "primary",
+ "secondary",
+ "accent",
+ "info",
+ "success",
+ "warning",
+];
+
+/**
+ * Map a workspace id to its tile tone. Deterministic (FNV-1a hash of the id)
+ * and uniformly distributed across the six tones — pure: id in, tone out.
+ */
+export function tileTone(id: string): TileTone {
+ let hash = 0x811c9dc5;
+ for (let index = 0; index < id.length; index++) {
+ hash ^= id.charCodeAt(index);
+ // FNV prime (16777619), kept in 32-bit unsigned range.
+ hash = Math.imul(hash, 0x01000193) >>> 0;
+ }
+ return TILE_TONES[hash % TILE_TONES.length] ?? "primary";
+}
+
+/**
* The browser tab / page (`document.title`) text for a route. The home route
* (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}",
* using the workspace's display title and falling back to the URL slug (`id`)
@@ -50,6 +84,20 @@ export function applyStarred<T extends WorkspaceEntry>(
}
/**
+ * Return a NEW list with the one workspace's `archived` flag set (immutably —
+ * the entry is replaced, the rest keep their identity). Pure: the optimistic
+ * archive/unarchive transformation shared by the apply + the error revert. A
+ * missing `id` leaves the list unchanged; a subsequent refresh reconciles.
+ */
+export function applyArchived<T extends WorkspaceEntry>(
+ workspaces: readonly T[],
+ id: string,
+ archived: boolean,
+): T[] {
+ return workspaces.map((w) => (w.id === id ? { ...w, archived } : w));
+}
+
+/**
* Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h",
* "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps
* (a workspace just created) read as "now".
@@ -62,10 +110,8 @@ export function relativeTime(then: number, now: number): string {
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
- if (days < 7) return `${days}d`;
- // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests.
- const d = new Date(then);
- const month = String(d.getUTCMonth() + 1).padStart(2, "0");
- const day = String(d.getUTCDate()).padStart(2, "0");
- return `${month}/${day}`;
+ if (days < 30) return `${days}d`;
+ // Past a month: months (a month ≈ 30 days). No short date — the label
+ // stays relative forever.
+ return `${Math.floor(days / 30)}mo`;
}