diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 11:47:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 11:47:18 +0900 |
| commit | 754450b539818f9ad94c9586888973b0edd74b68 (patch) | |
| tree | d852f9a8910f223e15a5e11ad04868b0d0a29804 /src/features/workspaces/tile-layout-store.test.ts | |
| parent | 719f0038dbb09f0fe9b073c3dc8e4f403815223d (diff) | |
| download | dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.tar.gz dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.zip | |
feat(workspaces): Win8 Start-style tile-grid home page
Rebuild the workspace home as a Windows 8 Start-screen-style tile grid,
replacing the max-w-3xl vertical card list.
Pure core (zero DOM/Svelte/fetch, fully unit-tested):
- logic/tile-layout.ts — geometryFor(width, height) resolves WIDE (4 fixed
~120px rows, column-wise flow, 4x2 span cap) or TALL (2 fixed columns,
row-wise flow, 2x2 cap); pack() is the ONLY order->coordinates transform
(greedy first-fit in fill order, hole-free, render-clamped per geometry);
firstEmptySlot/insertAtFirstEmptySlot/mergeLayout/resizeTile/moveTile/
sortByRecentActivity/sanitizeLayout. Persisted data is
orientation-independent {version:1, tiles:[{id,colSpan,rowSpan,order}]} —
order + spans only, never positions; the same data renders in either
geometry (spans clamp for the render, persisted values untouched).
- logic/drag-controller.ts — pure pointer/press state machine: mouse
threshold-drag + tap-open; touch long-press lifts + selects, pre-lift
movement cancels so vertical scrolling never fights the gesture.
Shell:
- tile-layout-store.svelte.ts — thin reactive wrapper persisting through
the injected createLocalStore adapter (dispatch.workspaces.tiles), ops:
sync/resize/move/placeFirstEmpty/sortByRecentActivity.
- ui/HomeTileGrid.svelte — CSS-grid tile canvas (wide: horizontal scroll +
snap + wheel pan + pan arrow + scroll indicator; tall: native vertical
scroll), FLIP-animated reflow, drag ghost, transient resize preview.
- ui/HomeHeader.svelte — fixed chrome: Dispatch display title, New
workspace popover (same slug validation; creates via create-on-miss and
does NOT navigate — the new tile lands in the first empty slot in fill
order and enters rename mode), global Plugins button, sort-by-recent
overflow; compact icon buttons + bottom sheet on tall screens.
- ui/WorkspaceTile.svelte — tile face (title, /slug, conversation count,
relative last-activity, live active dots, starred tint); inline rename
with the old card's semantics.
- ui/TileActionBar.svelte — slides up on select (right-click / long-press):
Open, Rename, Resize, Star/Unstar, Details, Plugins, Delete (old confirm
+ store call preserved).
- ui/ResizePicker.svelte — Word-style insert-table hover grid (wide 4x2 /
tall 2x2) with arrow/Enter/Escape support and ghost preview on commit.
- ui/WorkspaceDetails.svelte — flyout holding the old card's default-cwd
input + default-computer select (same store calls + error states).
- WorkspacesHome.svelte orchestrates; App.svelte untouched (same props).
- WorkspaceCard.svelte is removed (replaced by the tile system).
Manual tile order rules; starred workspaces get a tint (star is no longer
a sort key); workspaces unknown to the layout append at the end.
vitest: jsdom gains PointerEvent (MouseEvent-based) + Element.animate
stubs — the pointer logic is the outermost edge for the drag tests.
Verification: svelte-check 0/0, vitest 1655 passed (102 files, twice),
biome clean, vite build OK.
Diffstat (limited to 'src/features/workspaces/tile-layout-store.test.ts')
| -rw-r--r-- | src/features/workspaces/tile-layout-store.test.ts | 140 |
1 files changed, 140 insertions, 0 deletions
diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts new file mode 100644 index 0000000..f8b30f7 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { LocalStore } from "../../adapters/local-storage"; +import { geometryFor, type TileLayoutData, type TileLayoutEntry } from "./logic/tile-layout"; +import { createTileLayoutStore } from "./tile-layout-store.svelte"; + +const WIDE = geometryFor(1920, 1080); + +/** An in-memory `LocalStore` (the injected outermost edge — no mocks of our own modules). */ +function memoryStore(initial: TileLayoutData | null = null): LocalStore<TileLayoutData> & { + saved: TileLayoutData[]; +} { + let value = initial; + const saved: TileLayoutData[] = []; + return { + saved, + load: () => value, + save(next: TileLayoutData) { + saved.push(next); + value = next; + }, + clear() { + value = null; + }, + }; +} + +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +describe("createTileLayoutStore", () => { + it("loads the persisted document at creation", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0, 2, 2)] }); + const store = createTileLayoutStore(persistence); + expect(store.entries).toEqual([tile("a", 0, 2, 2)]); + }); + + it("sanitizes a corrupt persisted document to empty", () => { + const persistence = memoryStore(null); + // The adapter's load is untyped at the boundary — simulate garbage. + const store = createTileLayoutStore({ + load: () => ({ junk: true }) as unknown as TileLayoutData, + save: () => {}, + clear: () => {}, + }); + expect(store.entries).toEqual([]); + expect(persistence.saved).toEqual([]); + }); + + it("sync appends unknown workspaces and drops deleted ones, persisting once", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0), tile("gone", 1)] }); + const store = createTileLayoutStore(persistence); + + store.sync(["a", "b"]); + + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + expect(persistence.saved).toHaveLength(1); + // The max order counts KEPT entries (the dropped one doesn't reserve a slot). + expect(persistence.saved[0]).toEqual({ + version: 1, + tiles: [tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }], + }); + }); + + it("sync is a no-op (no write) when the merge changes nothing", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); + const store = createTileLayoutStore(persistence); + + store.sync(["a"]); + store.sync(["a"]); + + expect(persistence.saved).toHaveLength(0); + }); + + it("resize persists the new spans without clamping", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); + const store = createTileLayoutStore(persistence); + + store.resize("a", 4, 2); + + expect(store.entries).toEqual([tile("a", 0, 4, 2)]); + expect(persistence.saved.at(-1)?.tiles).toEqual([tile("a", 0, 4, 2)]); + }); + + it("move persists the reordered layout", () => { + const persistence = memoryStore({ + version: 1, + tiles: [tile("a", 0), tile("b", 1), tile("c", 2)], + }); + const store = createTileLayoutStore(persistence); + + store.move("c", 0); + + expect(store.entries.map((t) => t.id)).toEqual(["c", "a", "b"]); + expect(store.entries.map((t) => t.order)).toEqual([0, 1, 2]); + }); + + it("placeFirstEmpty puts a new id in the first empty slot's fill position", () => { + const persistence = memoryStore({ + version: 1, + tiles: [tile("a", 0, 1, 2), tile("b", 1)], + }); + const store = createTileLayoutStore(persistence); + + store.placeFirstEmpty("new", WIDE); + + // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → appended. + expect(store.entries.map((t) => t.id)).toEqual(["a", "b", "new"]); + expect(persistence.saved.at(-1)?.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); + }); + + it("placeFirstEmpty is idempotent for an id sync already appended (the refresh race)", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); + const store = createTileLayoutStore(persistence); + store.sync(["a", "x"]); // the refresh appended the new workspace at the end + const before = store.entries; + + store.placeFirstEmpty("x", WIDE); + + expect(store.entries).toEqual(before); + expect(store.entries.filter((t) => t.id === "x")).toHaveLength(1); + }); + + it("sortByRecentActivity re-applies recent-activity order and persists", () => { + const persistence = memoryStore({ + version: 1, + tiles: [tile("a", 0), tile("b", 1)], + }); + const store = createTileLayoutStore(persistence); + + store.sortByRecentActivity( + new Map([ + ["a", 10], + ["b", 99], + ]), + ); + + expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]); + }); +}); |
