diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 19:16:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 19:16:08 +0900 |
| commit | 9c201c60a727106f1e3bf8c2af5f86aa7427098f (patch) | |
| tree | 37c0fa07913154386785126bd75093866f05c975 /src/features/workspaces/ui | |
| parent | 61733924352285df6c44f66edf69ea6f738fae3e (diff) | |
| download | dispatch-web-9c201c60a727106f1e3bf8c2af5f86aa7427098f.tar.gz dispatch-web-9c201c60a727106f1e3bf8c2af5f86aa7427098f.zip | |
feat(workspaces): sync the home tile layout per device orientation
The Win8-Start home grid's arrangement now syncs from the backend's
home-tile-layout plugin: ONE shared document per device orientation
(wide = landscape/desktop, tall = portrait/mobile) — every landscape
device renders the same layout, every portrait device the same other
one. localStorage persistence is retired (no migration — devices start
from the shared document; the first arrangement creates it).
- adapter/tile-layout-http.ts: the injected HTTP edge (GET/PUT/DELETE
/home/tile-layout/:mode), the features/layout adapter's pattern
- tile-layout-store: server-backed, keyed by geometry mode — boot +
orientation-change probes (stale in-flight responses never land),
locally-applied edits + debounced full-document PUT with rollback to
the last server document, WS push filtered to the active orientation,
quiet ephemeral degrade when the backend is unreachable (no error UI),
sync gated while a probe is in flight
- WorkspacesHome locks the store's orientation to the resolved grid
geometry (the landscape/portrait split IS the device-type split)
- app store: the tile store lives beside the layout store; the WS
dispatch feeds 'tile-layout' broadcasts into it; 'tiles' prop from
App.svelte (the page entry owns the httpBase)
- ws adapter: the 'tile-layout' server message (parse guard) + socket
handler; wire conformance case
Diffstat (limited to 'src/features/workspaces/ui')
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 49 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.test.ts | 56 |
2 files changed, 59 insertions, 46 deletions
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index 7c0db85..798d1bb 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -7,7 +7,6 @@ type PluginsStore, type WorkspaceLayoutPort, } from "../../plugins"; - import { createLocalStore } from "../../../adapters/local-storage"; import type { WorkspaceStore } from "../store.svelte"; import { workspacePath } from "../logic/route"; import { @@ -17,11 +16,7 @@ type GridGeometry, type Span, } from "../logic/tile-layout"; - import { - createTileLayoutStore, - TILE_LAYOUT_STORAGE_KEY, - type TileLayoutStore, - } from "../tile-layout-store.svelte"; + import { type TileLayoutStore } from "../tile-layout-store.svelte"; import HomeHeader from "./HomeHeader.svelte"; import HomeTileGrid from "./HomeTileGrid.svelte"; import TileActionBar from "./TileActionBar.svelte"; @@ -59,33 +54,30 @@ */ layout?: WorkspaceLayoutPort; /** - * The persisted tile layout store. Absent → created here over the real - * localStorage adapter (the page is the effect edge); tests inject one. + * The synced tile layout store (ONE shared document per device + * orientation, persisted by the backend's home-tile-layout plugin). + * Created by the page entry (App.svelte — the composition root that owns + * the httpBase); tests inject one. */ - tiles?: TileLayoutStore; + tiles: TileLayoutStore; } = $props(); onMount(() => { void store.refresh(); }); - // ── The persisted tile layout (order + spans only — never positions). ───── - // The store is created once per page from the (inert) override prop — the - // repo's `untrack` pattern for initial-value captures (cf. App.svelte). - const tiles: TileLayoutStore = - untrack(() => tilesOverride) ?? - createTileLayoutStore( - createLocalStore(TILE_LAYOUT_STORAGE_KEY, { - storage: typeof localStorage !== "undefined" ? localStorage : undefined, - }), - ); + // The store arrives fully wired (the page entry created it over the real + // HTTP edge). The `untrack` capture (the repo's initial-value pattern, cf. + // App.svelte) dealiases the prop once — the component instance is the + // store's single owner from here on. + const tiles: TileLayoutStore = untrack(() => tilesOverride); // Keep the layout merged with the live workspace list: unknown workspaces - // (deep-link auto-creates) append at the end; deleted ones drop. The no-op - // guard inside sync keeps this effect from looping. Skipped until the first - // non-empty list arrives — `sync([])` before the initial `refresh()` resolves - // would WIPE the persisted order (the backend always serves "default", so an - // empty list only ever means "not loaded yet"). + // (deep-link auto-creates) append at the end; deleted ones drop. The + // no-op guards inside sync keep this effect from looping. Skipped until + // the first non-empty list arrives — `sync([])` before the initial + // `refresh()` resolves would WIPE the persisted order (the backend always + // serves "default", so an empty list only ever means "not loaded yet"). $effect(() => { const ids = store.list.map((ws) => ws.id); if (ids.length === 0) return; @@ -111,6 +103,15 @@ return () => observer.disconnect(); }); + // Lock the store's active orientation to the resolved grid geometry: the + // landscape/portrait split IS the device-type split — every landscape + // device shares the `wide` document, every portrait device the `tall` one. + // Same-mode calls are a no-op inside the store; an orientation change + // resets + re-probes (never flashing the other orientation's arrangement). + $effect(() => { + tiles.setMode(geometry.mode); + }); + // The grid view: the SAME persisted data packs into either geometry — // positions re-derive here, spans clamp for the RENDER only. const placements = $derived(pack(tiles.entries, geometry)); diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts index eb5b293..9e8411a 100644 --- a/src/features/workspaces/ui/WorkspacesHome.test.ts +++ b/src/features/workspaces/ui/WorkspacesHome.test.ts @@ -1,10 +1,10 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import { render, screen, waitFor } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import type { LocalStore } from "../../../adapters/local-storage"; import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http"; -import type { TileLayoutData, TileLayoutEntry } from "../logic/tile-layout"; +import type { TileLayoutHttp } from "../adapter/tile-layout-http"; import { createTileLayoutStore } from "../tile-layout-store.svelte"; import WorkspacesHome from "./WorkspacesHome.svelte"; @@ -46,27 +46,37 @@ function fakeHttp(list: WorkspaceEntry[] = [ws()]): WorkspaceHttp { } as unknown as WorkspaceHttp; } -function memoryLayout(initial: TileLayoutData | null = null): LocalStore<TileLayoutData> & { - data: TileLayoutData | null; -} { - const store = { - data: initial, - load: () => store.data, - save: (value: TileLayoutData) => { - store.data = value; +/** + * A fake tile-layout HTTP edge serving ONE controllable document per mode + * (no mocks of our modules). `docs` is the live store — assertions read it + * after `waitFor` (the boot probe + debounced sync writes land there). + */ +function memoryLayout( + initial: TileLayoutData | null = null, +): TileLayoutHttp & { docs: Map<TileGeometryMode, TileLayoutData> } { + const docs = new Map<TileGeometryMode, TileLayoutData>(); + if (initial !== null) docs.set("wide", initial); + return { + docs, + async getLayout(mode) { + return { ok: true, layout: docs.get(mode) ?? { version: 2, tiles: [] } }; + }, + async putLayout(mode, layout) { + docs.set(mode, layout); + return { ok: true, layout }; }, - clear: () => { - store.data = null; + async deleteLayout(mode) { + docs.set(mode, { version: 2, tiles: [] }); + return { ok: true, layout: docs.get(mode) as TileLayoutData }; }, }; - return store; } describe("WorkspacesHome (tile canvas)", () => { it("renders one tile per workspace, in the persisted tile order (not the list order)", async () => { const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; const layout = memoryLayout({ - version: 1, + version: 2, tiles: [ { id: "b", order: 0, colSpan: 1, rowSpan: 1 }, { id: "a", order: 1, colSpan: 1, rowSpan: 1 }, @@ -77,7 +87,7 @@ describe("WorkspacesHome (tile canvas)", () => { store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), onNavigate: vi.fn(), computers: [], - tiles: createTileLayoutStore(layout), + tiles: createTileLayoutStore({ http: layout }), }, }); @@ -94,7 +104,7 @@ describe("WorkspacesHome (tile canvas)", () => { store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()), onNavigate, computers: [], - tiles: createTileLayoutStore(memoryLayout()), + tiles: createTileLayoutStore({ http: memoryLayout() }), }, }); @@ -113,7 +123,7 @@ describe("WorkspacesHome (tile canvas)", () => { store, onNavigate, computers: [], - tiles: createTileLayoutStore(memoryLayout()), + tiles: createTileLayoutStore({ http: memoryLayout() }), }, }); @@ -139,7 +149,7 @@ describe("WorkspacesHome (tile canvas)", () => { store, onNavigate: vi.fn(), computers: [], - tiles: createTileLayoutStore(memoryLayout()), + tiles: createTileLayoutStore({ http: memoryLayout() }), }, }); @@ -159,7 +169,7 @@ describe("WorkspacesHome (tile canvas)", () => { it("workspaces unknown to the layout append at the end (deep-link auto-create)", async () => { const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; const layout = memoryLayout({ - version: 1, + version: 2, tiles: [{ id: "b", order: 0, colSpan: 1, rowSpan: 1 }], }); render(WorkspacesHome, { @@ -167,7 +177,7 @@ describe("WorkspacesHome (tile canvas)", () => { store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), onNavigate: vi.fn(), computers: [], - tiles: createTileLayoutStore(layout), + tiles: createTileLayoutStore({ http: layout }), }, }); @@ -176,7 +186,9 @@ describe("WorkspacesHome (tile canvas)", () => { const wrappers = document.querySelectorAll("[data-tile-id]"); expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]); // And the sync persisted the appended entry. - const entries = (layout.data?.tiles ?? []).map((tile: TileLayoutEntry) => tile.id); - expect(entries).toEqual(["b", "a"]); + await waitFor(() => { + const tiles = layout.docs.get("wide")?.tiles ?? []; + expect(tiles.map((tile) => tile.id)).toEqual(["b", "a"]); + }); }); }); |
