diff options
Diffstat (limited to 'src/features/workspaces/logic')
| -rw-r--r-- | src/features/workspaces/logic/create.test.ts | 50 | ||||
| -rw-r--r-- | src/features/workspaces/logic/create.ts | 64 | ||||
| -rw-r--r-- | src/features/workspaces/logic/drag-controller.test.ts | 180 | ||||
| -rw-r--r-- | src/features/workspaces/logic/drag-controller.ts | 162 | ||||
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.test.ts | 857 | ||||
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.ts | 709 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.test.ts | 98 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.ts | 58 |
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`; } |
