summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 19:16:08 +0900
committerAdam Malczewski <[email protected]>2026-09-07 19:16:08 +0900
commit9c201c60a727106f1e3bf8c2af5f86aa7427098f (patch)
tree37c0fa07913154386785126bd75093866f05c975 /src/features/workspaces/ui
parent61733924352285df6c44f66edf69ea6f738fae3e (diff)
downloaddispatch-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.svelte49
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.test.ts56
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"]);
+ });
});
});