2026 03 21 Hierarchical Dock Layout User Preferences Spec

Documentation for Plans

Hierarchical Dock Layout Persistence — Specification

Date: 2026-03-21
Status: Draft — partially implemented (S253): backend GET/PUT /api/spaces/:spaceId/me/dock-layout-presets, space DB merge via dockLayoutPresetsByStorageKey, @libs-ui/dock-layout optional DOCK_LAYOUT_CLOUD_SYNC + container inputs, Vitto Studio HTTP adapter + shell wiring.
Scope: Per-user, per-space dock layout presets for apps using @libs-ui/dock-layout (e.g. Vitto Studio), with server sync, local cache, and performance guarantees.

Related code:

Area Location
Dock presets (client, v2 localStorage) libs/libs-ui/dock-layout — DockLayoutService, SavedLayoutProfile in layout-node.model.ts
Global user preferences (Mongo, main API DB) libs/libs-api/backend-core — user-preferences.schema.ts, UserPreferencesService; HTTP users/me/preferences
Merged prefs + Redis APIs/backend-api/src/app/preferences/preferences.service.ts
Space-scoped user prefs (per-space Mongo DB) APIs/backend-api/src/app/spaces/space-database/services/space-database-preferences.service.ts
Space prefs HTTP + membership APIs/backend-api/src/app/preferences/preferences.controller.ts
canUserModify / flatten libs/core/shared-models/src/lib/services/preference-resolver.ts

0. Verified facts — APIs/backend-api (code as of this document)

The following were read from the repo; do not rely on unverified assumptions when implementing.

0.1 HTTP surface

0.2 Where space user preferences are stored

SpaceDatabasePreferencesService.saveUserSpacePreferences / getUserSpacePreferences:

0.3 What happens on PUT /api/spaces/:spaceId/preferences/me

PreferencesService.updateUserSpacePreferences:

  1. Loads space preference_overrides config.
  2. flattenPreferences(input) on the request body.
  3. For each flattened key, throws ForbiddenException if the key is enforced or PreferenceResolver.canUserModify returns false.

Implication for dock data: For a key with no matching settingRules entry, getSettingRule is undefined, getEffectivePolicy falls back to the space defaultPolicy, which is space-wins on the default config created in getPreferenceOverrideConfig. Then canUserModify returns false unless a user-specific override grants user-wins for that user. So you cannot add arbitrary dock-related keys to the existing PUT .../preferences/me body without also extending preference_overrides.settingRules (and related resolver behavior) or changing this gate.

0.4 Typed input today

UpdateUserSpacePreferencesInput in preferences.service.ts only allows: theme, language, timezone, dateFormat, timeFormat, notifications, interface (subset). The Mongo layer’s saveUserSpacePreferences TypeScript parameter matches that shape; runtime is still driven by the object passed from the service.

0.5 Authorization

User endpoints call requireSpaceMember: allows user.role === 'system', space owner, or any member. Otherwise ForbiddenException (NotFoundException if space missing).

0.6 Merged preferences cache

getMergedPreferences uses Redis key prefs:${spaceId}:${userId} with TTL PREFS_CACHE_TTL = 120 seconds when Redis is connected. No DEL / invalidation of that key appears in updateUserSpacePreferences in the current file — stale merged results are possible for up to ~120s after a successful space prefs PUT (until TTL expires or another process invalidates). Dock sync design should not rely on merged prefs refreshing instantly unless this is fixed separately.

0.7 Concurrency / revision

Space user_preferences documents have createdAt / updatedAt on write, but no revision / optimistic-lock field exists in the current save path.

0.8 GDPR / export (scoped check)

GET /api/users/me/preferences/export exists on users.controller.ts for global user preferences. A quick search under APIs/backend-api did not find an equivalent export that reads per-space DB user_preferences; whether space-scoped prefs are included in any export pipeline must be verified elsewhere (docs-api, jobs, or future work).

0.9 Client storageKey today (cross-space localStorage)

vitto-studio provides storageKey: 'vitto-studio-dock-layout-v2' in studio-dock-shell.component.ts — no spaceId in the string. Presets persist under vitto-studio-dock-layout-v2-presets in localStorage (DockLayoutService.presetsStorageKey()). Switching spaces on the same origin can share one local preset blob unless the app namespaces the key or migrates keys per space.


1. Goals

  1. Hierarchical resolution: User sees a layout derived from defaults first; saved user data only overrides when present.
  2. Additive feature: Users with no saved server data behave exactly as today (code default + optional localStorage).
  3. Per-space: A user’s Vitto Studio layout in Space A does not overwrite their layout in Space B.
  4. Performance: No regression to first paint, drag/splitter interaction, or unrelated preference reads.
  5. UX: Clear sync state (optional subtle indicator), no blocking spinners on routine navigation, graceful offline and conflict behavior.

2. Non-goals (v1)

3. Resolution hierarchy

Resolve effective presets store for (userId, spaceId, appStorageKey) in strict order; first defined layer wins for that field only where noted.

Layer Name Source Applies to
L0 App built-in default Code (DockLayoutService / container config) Initial LayoutState when nothing else applies
L1 (Optional future) Space template Space settings or admin config Default before any user save
L2 User session / ephemeral In-memory only Current tab session
L3a User local cache localStorage key ${storageKey}-presets (v2 blob) Offline, fast, same-device
L3b User server copy Per-space user preference document (see §4) Cross-device, durable

v1 rule (explicit): L1 is absent. Effective hierarchy is L0 → L3a ↔ L3b (see merge rules below) → L2 is just runtime.

Hydration merge (client, on authenticated load):

  1. Parse L3a (local) and L3b (server) if both exist.
  2. Compare revision (if implemented — not in current backend-api save path; until then use updatedAt + content hash) from §4.
  3. If server newer: replace local presets store for that (spaceId, storageKey) and write to localStorage.
  4. If local newer (e.g. offline edits): keep local UI; queue debounced PUT; on next successful GET, converge to server.
  5. If equal: no write.

Unauthenticated: Use L0 + L3a only; never call sync API.

4. Data model

4.1 Canonical blob: DockLayoutPresetsStore

Aligned with existing client shape (extend, do not fork silently):

/** Persisted per (spaceId, userId, storageKey) — v1 server contract */
interface DockLayoutPresetsStore {
  /** Schema version for migrations (mirror client; e.g. 2) */
  version: number;
  /** Monotonic revision for optimistic concurrency (server-assigned on write) */
  revision: number;
  /** Last server persistence time (ISO 8601) */
  updatedAt: string;
  /** Same semantics as client today */
  activeProfileId: string | null;
  profiles: SavedLayoutProfile[];
}

Physical document (fits current storage): There is already one document per (spaceId, userId) in each space’s user_preferences collection (_id: user-pref-${userId}). The nested object is today the whole preferences field. A map such as preferences.dockLayoutPresetsByStorageKey is compatible with that shape if every PUT that touches prefs merges with the existing preferences object server-side or the client sends the full merged preferences object — see §0.2 (full replace on save today).

4.2 Global vs space DB user_preferences (names collide; databases differ)

4.3 Concurrency

5. API design

5.1 Why not only extend PUT /api/spaces/:spaceId/preferences/me?

Per §0.3, new flattened keys are denied by canUserModify under default space policy. Even if rules were extended:

Recommended v1: Dedicated routes (names illustrative) implemented in backend-api, reusing requireSpaceMember (same pattern as PreferencesController):

Alternative (higher risk): Extend preference_overrides.settingRules so dock keys are user-wins, and change saveUserSpacePreferences to $set dot-paths or read-merge-write the preferences object so partial updates never erase sibling fields. This touches more surfaces (modifiable settings UI, resolver, admin tooling).

Auth: Mirror requireSpaceMember from preferences.controller.ts (§0.5).

5.2 Explicit exclusion

5.3 Cache (server)

5.4 Angular client

6. Performance requirements

Concern Requirement
First paint L0 applies immediately; server GET must not block shell render. Use defer (afterNextRender, route resolve: false, or idle callback) unless store is tiny and cached.
Drag / resize Zero network calls during pointer streams; only local DockLayoutService updates.
Writes Debounce PUT (e.g. 1–3s after last “save profile” / “rename” / “delete”) or explicit “Sync now” only for power users — v1 recommendation: explicit save actions + optional debounce for “update snapshot.”
Payload size Cap profiles count and serialized size (§7).
Main thread JSON parse/stringify of large blobs off hot path where possible (chunk or idle); avoid cloning full tree on every CD cycle.
Merged prefs No increase in size/frequency of existing merged-prefs fetches.

Acceptance (measurable): With cold cache, Vitto Studio shell Time to Interactive delta vs baseline ≤ one frame budget of async work attributable to dock sync (target: < 50ms main-thread work after response, excluding network).

7. Limits and validation

Enforce on server (authoritative) and client (UX):

Limit Suggested v1 On exceed
Profiles per (spaceId, storageKey) 20 400 + message; client prevents enqueue
Max JSON size per store 256 KB – 512 KB (pick one; document in config) 413 / 400
Profile name length 128 chars trim / reject
storageKey allowlist Regex ^[a-z0-9][a-z0-9-]{0,63}$ 400

Sanitization on read (server): Strip unknown top-level keys; validate version; if unsupported major version, return null or empty store + log (client falls back to L0/L3a).

8. User experience

8.1 States

8.2 Offline

8.3 Logout / user switch

9. Edge cases matrix

# Scenario Expected behavior
E1 No server document L0 + local only; first PUT creates server row
E2 Server empty, rich local Optional one-time migration: “Back up to account” prompt or silent upload on first authenticated visit (product choice)
E3 Two browser tabs, same user Same as concurrent devices: revision / 409; last successful write wins unless user resolves
E4 App deploy removes a panel type Sanitize on apply: drop unknown panel nodes; log metric; optional toast “Some panels were removed”
E5 App deploy changes layout schema version Client migration runs first; if server has old version, migration on read or reject with fallback to L0
E6 User removed from space GET/PUT 403; client drops server copy for that spaceId; local cache may remain but scoped key should include spaceId
E7 Space deleted Same as E6
E8 GDPR export Global prefs export exists at /api/users/me/preferences/export; per-space user_preferences export not found in backend-api grep — define whether dock data must appear in a space/user export and where that job lives
E15 Partial PUT .../preferences/me With current $set of whole preferences, a partial body drops omitted keys; clients must send full merged prefs or server must merge
E16 Dock fields on existing PUT Forbidden for new keys under default space-wins / canUserModify (§0.3)
E9 GDPR delete Cascade delete or anonymize preset blobs with user space membership
E10 storageKey typo in new app Isolated empty store; no cross-app leakage
E11 Malicious huge payload Reject at size limit; rate-limit PUT per user
E12 Redis stale cache TTL + invalidate on write; accept brief staleness or skip Redis for dock v1
E13 User clears site data Local gone; next session pulls server if any
E14 Server has data, user wants “factory reset” UI action: delete all profiles server + local, reapply L0

10. Security and tenancy

11. Observability

12. Future: Space default template (L1)

If product requires “Space admin sets default Vitto Studio layout for new members”:

This is out of v1 unless explicitly prioritized.

13. Implementation phases

  1. Phase A — Server: Schema field + GET/PUT + validation + limits + 409 revision; no UI change.
  2. Phase B — Client sync service: Hydrate after auth + space context; debounced PUT on save actions; localStorage key includes spaceId to fix E6/E10 cross-space leakage in local cache.
  3. Phase C — UX: Conflict banner, sync indicator, optional “backup to account.”
  4. Phase D — Docs & export: GDPR and admin docs.

14. Acceptance criteria (testing)

15. Open questions (resolve before coding)

  1. localStorage namespacing: Verified: Vitto Studio uses storageKey: 'vitto-studio-dock-layout-v2' (no spaceId). Phase B must either pass a space-scoped storageKey from the shell or teach dock-layout to suffix localStorage keys with spaceId when provided via config.
  2. Persistence location for dedicated dock API: Same document’s preferences.dockLayoutPresetsByStorageKey (requires safe merge on write) vs new collection in the space DB (e.g. dock_layout_presets) — trade-offs: document size vs query simplicity.
  3. Conflict UX: Auto server-wins vs mandatory user prompt for 409.
  4. Silent cloud backup on first login vs explicit user consent (enterprise may care).
  5. Invalidate merged prefs cache on space prefs update: Should updateUserSpacePreferences delete prefs:${spaceId}:${userId} when Redis is available (fixes §0.6 for theme/language flows; orthogonal but recommended if dock ever touches same doc).

Document maintenance: Update revision / version semantics here when the client DockLayoutPresetsStore on disk format changes; keep this spec synchronized with SavedLayoutProfile in layout-node.model.ts.