Files
oikos/plans/2026-07-21-frontend-os-apps-architecture.md
dtoro 482c7f3448
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
feat(web): app-registry architecture — OS + Apps, lazy loading, installable apps
Problem: the frontend had an implicit OS+Apps metaphor (desktop, floating
windows, an app registry) but the contract was informal — the mascot was
hardcoded into the shell, all apps were statically imported into one
800KB bundle, and there was no install/uninstall path.

Change: three phases landed.
- Phase 1 (contract + docked kind): AppDef extended with docked/noIcon
  and optional geometry; the mascot registered as a docked app via a
  generic DockedLayer that replaces the hardcoded <MascotLayer />;
  openAppWindow branches on docked → toggleDocked; persisted docked
  visibility store (absent key = visible, no APPS import to avoid a
  static cycle).
- Phase 2 (lazy loading): AppDef.component is now a dynamic-import
  loader; LazyApp renders with a loading skeleton; Vite code-splits
  each app (main bundle 800KB→485KB); the LazyMascot wrapper is gone
  since the lazy loader breaks the import cycle directly.
- Phase 3 (installable apps, local bundles): AppManifest + catalog +
  installApp/uninstallApp + localStorage persistence; reactive apps
  store (built-in + installed) and derived appById; App Store page;
  Notes demo app; icons.ts and WindowLayer's orphan-close react to
  registration so installs appear without a reload.
- Structure: data-table casing unified to PascalCase; the mislabeled
  DataTable.svelte.ts (pure types, not runes) renamed to types.ts;
  LazyApp colocated with its desktop-shell consumers; app-store moved
  under lib/ so the dependency direction is consistent.

Risk: the app registry is now a reactive store, not a static array, so
every consumer (Desktop, DockedLayer, Taskbar, icons, windows) reads
from derived stores. Two static-cycle traps are documented in
docs/mbse/components.md §9: docked.ts must not import APPS (it would
fire a TDZ at init via the apps.ts→pages→windows.ts→here path), and
apps.ts must not statically import the mascot (the lazy loader defers
its module graph). Remote bundle loading, the /api/v1/apps endpoint,
and permission enforcement are deliberately NOT in this commit — they
are security-critical and deferred to Phase 4 with an ADR.

Verification: vitest 38/38; svelte-check + tsc clean for changed files;
eslint clean; vite build green; runtime smoke confirmed (install
Notes → icon appears → open → uninstall → icon + window gone; survives
reload). docs/mbse/components.md Component 9 and the plan updated.

Plan: plans/2026-07-21-frontend-os-apps-architecture.md
2026-07-21 14:37:36 +02:00

29 KiB
Raw Blame History

Frontend as OS + Apps: architecture audit & refactor plan

Status: Planned Stakeholders: Operator, Nomos Confidence: Verified (direct code audit against web/src/ as of 2026-07-21)


Summary

The Oikos frontend is already built on an implicit OS + Apps metaphor — a desktop surface, floating windows, a taskbar, and a registry of independently-rendered apps. This plan makes that metaphor explicit, strengthens the contracts between Base OS and Apps, refactors the mascot into a proper App, and lays out the extensibility path for dynamic app installation without touching shell code.

The current codebase is remarkably close. The audit found one structural gap (mascot is hardcoded into the shell, not a registry App) and three contract weaknesses (positional content resolution, icon store assumes a static registry, no stable OS-service contract for Apps). Fixing them requires no architectural rewrite — the bones are correct.


1. Audit: what we have today

1.1 The implicit OS layer (exists, undocumented)

Service File Role
Window Manager lib/stores/windows.ts:19-31 wmkit manager + desktop + persist. Single-instance, global.
Desktop Surface components/desktop-shell/Desktop.svelte Full-viewport shell: background, icons, launcher, windows, mascot, taskbar.
Window Layer components/desktop-shell/WindowLayer.svelte Content resolver: maps window ID → component. z-40.
Taskbar components/desktop-shell/Taskbar.svelte Window buttons + tray. Renders from wmState.order.
Icon Grid lib/stores/icons.ts Column/row grid, drag-to-reorder, localStorage persistence.
Task Launcher components/desktop-shell/TaskLauncher.svelte Centered text input → new task window.
Auth Gate App.svelte Config screen vs. Desktop. Token check, OIDC init, context/SSE subscribe.
Session Windows components/SessionChatWindow.svelte Per-session chat window, splitpanes layout.
New Task Window components/desktop-shell/NewTaskChat.svelte Singleton compose window.
Entity Windows components/EntityDetailContent.svelte Entity detail (bare slug window IDs).
Legacy Hash Routes App.svelte:17-39 Backward compat for old #/kb, #/entity/<slug> bookmarks.

The shell has no hardcoded app listDesktop.svelte:90 reads APPS from the registry, WindowLayer.svelte:36-37 resolves app windows through appById, Taskbar.svelte:32 resolves icons the same way. Adding an app is one entry in apps.ts.

1.2 The App Registry (exists, nearly complete)

File: lib/apps.ts (130 lines) Interface: AppDef — id, title, icon (Lucide Component), component (Svelte Component), width, height, minWidth, minHeight, optional badge function. Window namespacing: app:<id> (apps.ts:122) — distinct from session:<id>, new-task, and bare entity slugs.

Current apps (7):

ID Page Component Badge?
tasks pages/Overview.svelte
kb pages/KnowledgeBase.svelte
ops pages/Ops.svelte approvals_pending
signals pages/Signals.svelte open signal count
knowledge pages/Knowledge.svelte
learning pages/Learning.svelte
settings pages/Settings.svelte

What works:

  • Data-driven. One array → three surfaces auto-render.
  • Namespaced window IDs prevent collisions with session/entity windows.
  • Single-instance enforcement (double-click focuses, never duplicates).
  • Badge system: pure function over DashboardSummary, consumed by icon + taskbar.
  • Tested (apps.test.ts): unique IDs, positive sizes, appById index, round-trips.
  • Orphan cleanup: WindowLayer.svelte:25-30 closes persisted windows whose app was removed from the registry.

What's missing from the AppDef contract:

  1. No stable OS-service surface. Apps reach into the OS by importing arbitrary $lib modules (openEntityWindow from windows.ts, summary from context.ts). It works because apps are compiled in, but there is no documented boundary between "stable OS API an App may use" and "shell internals that happen to be exported." Phase 3 (installed third-party apps) needs that boundary to exist first.
  2. No docked/overlay app kind. An app that renders on the desktop (above windows, no titlebar, no window at all) has no representation in the contract — which is exactly why the mascot is hardcoded.

1.3 The Mascot: embedded, not an app

Files: lib/mascot/ (12 files, ~2.8k lines) Integration: Desktop.svelte:105 — hardcoded <MascotLayer /> at z-45, after WindowLayer and before Taskbar.

Key facts that shape the refactor (verified):

  • MascotLayer.svelte takes no props. It creates the MascotRuntime per mount, seeds position from the persisted model, and attaches the stimulus bus itself (MascotLayer.svelte:38-61, comment at line 6-7).
  • The persistent model (stage, name, happiness, xp, lastPos) is module-scoped in state.svelte.ts and survives unmount/remount.
  • The sprite Image cache is module-scoped in sprites.ts — remounts do not re-fetch the 19 PNG sheets.
  • The stimulus bus subscribes to global stores (focusedSessionId from windows.ts, per-session factories from chat.ts/workspace.ts) — no dependency on how MascotLayer is mounted.

Consequence: hiding the mascot = {#if visible}<MascotLayer />{/if}. State, sprites, and position all restore naturally. No keepAlive machinery is needed.

1.4 Three contract weaknesses

Weakness 1: Positional content resolution

WindowLayer.svelte:70-79 resolves content by checking ID patterns in a hardcoded order:

{#if id.startsWith(SESSION_PREFIX)}
  <SessionChatWindow ... />
{:else if id === NEW_TASK_WINDOW_ID}
  <NewTaskChat />
{:else if app}
  <app.component />
{:else}
  <EntityDetailContent ... />
{/if}

A new window category must be inserted at the right position in this chain. Works today because prefixes are mutually exclusive by construction, but it's a landmine: add 'lxc:' container consoles or 'log:' viewers and you're editing shell internals.

Weakness 2: Icon store snapshots the registry at module load

icons.ts:23 builds default positions from APPS, and icons.ts:48 freezes an appIds set used to filter persisted positions in load(). Both evaluate once at import time. A late-registering app (lazy load, Phase 2+) would have its persisted position silently dropped by the load() filter — the merge-over-defaults logic only helps apps that were already in APPS when the module first evaluated.

Weakness 3: Window chrome is fully shell-owned, with no extension point

Every window gets the same titlebar (WindowLayer.svelte:40-67): drag handle, title, minimize/maximize/close. Correct default — apps should not draw their own chrome — but there is no sanctioned way for an app to contribute a titlebar affordance (e.g. Tasks might want an inline "New task" button). Decision: document as a designed extension point, defer implementation until an app actually needs it (see §2.5). Not a Phase 1 deliverable.


2. The OS + Apps model

2.1 Metaphor

┌──────────────────────────────────────────────────┐
│  Auth Gate (App.svelte)                          │
│  ┌──────────────────────────────────────────────┐│
│  │  Desktop Surface                             ││
│  │  ┌─────────────┐  ┌─────────────┐            ││
│  │  │ App Window   │  │ App Window   │  z-40    ││
│  │  │ (Tasks)      │  │ (Signals)    │          ││
│  │  └─────────────┘  └─────────────┘            ││
│  │  ┌──────────────────────┐                    ││
│  │  │ Docked Apps (Cluck)  │  z-45, no chrome   ││
│  │  └──────────────────────┘                    ││
│  │  ┌──────┐ ┌──────┐ ┌──────┐  z-0             ││
│  │  │ Icon │ │ Icon │ │ Icon │                  ││
│  │  └──────┘ └──────┘ └──────┘                  ││
│  └──────────────────────────────────────────────┘│
│  ┌──────────────────────────────────────────────┐│
│  │  Taskbar  [Tasks] [Signals]    🎨 ⚙ v0.9     ││
│  └──────────────────────────────────────────────┘│
└──────────────────────────────────────────────────┘

Base OS = Auth Gate + Desktop Surface + Window Manager + Taskbar
        + Icon Grid + Docked Layer + OS-service surface
Apps    = Tasks, KB, Ops, Signals, Knowledge, Learning, Settings, Cluck

2.2 App kinds

Two kinds, distinguished by one flag:

Kind Window Titlebar Taskbar button Opened by
Windowed (default) wmkit floating window Yes Yes (automatic) openAppWindow(id)wm.open()
Docked (docked: true) None — renders on the Docked Layer No No openAppWindow(id) → toggles visibility

Docked apps are not wmkit citizens. They render in a dedicated layer above the window layer, their visibility is a persisted boolean, and clicking their desktop icon toggles show/hide. They never appear in the taskbar because they never enter wmState.order.

2.3 The App contract

interface AppDef {
  // Identity (required)
  id: string                    // unique; window IDs are "app:<id>"
  title: string                 // desktop icon label + window titlebar
  icon: Component               // Lucide icon (desktop icon + taskbar)
  component: Component          // Svelte component; receives NO props

  // Kind
  docked?: boolean              // true = Docked Layer app, no window (default false)

  // Window geometry — required for windowed apps, forbidden for docked apps
  width?: number
  height?: number
  minWidth?: number
  minHeight?: number

  // Behavior (all optional)
  badge?: (summary: DashboardSummary | null) => number
  noIcon?: boolean              // true = registered but no desktop icon
}

Validation rules (enforced by apps.test.ts, not runtime checks):

  • id unique, non-empty.
  • Windowed apps: width/height present and positive.
  • Docked apps: width/height absent (geometry is meaningless without a window).
  • Every app has an icon component (even noIcon apps — the taskbar and future surfaces need it).

Design decisions, and why:

  • No keepAlive. Module-scoped state (mascot model, sprite cache) already survives unmount. If a future app needs close-to-hide semantics, that's a wmkit feature request, not an AppDef field.
  • No noTaskbar. Docked apps never reach the taskbar; windowed apps always should. A windowed app with no taskbar button is an orphan the operator can't find.
  • No lifecycle hooks in the contract. Svelte's own onMount/onDestroy already fire on window open/close. A shell-level onRegister is only meaningful once apps register dynamically — deferred to Phase 3, where it becomes the permission handshake.
  • Apps receive no props. The component is the app. It imports OS services (§2.4) directly. This keeps the shell→app edge one-way and trivially mockable.

2.4 The OS-service surface (AppOS)

The stable set of $lib exports an App may import. Everything else in $lib is shell-internal and may change without notice. This is a documentation contract today (apps are compiled in); it becomes an enforced sandbox boundary in Phase 3.

Service Import Stability
Open an app window openAppWindow(id) from $lib/stores/windows Stable
Open an entity window openEntityWindow(slug) from $lib/stores/windows Stable
Open a task window openTaskWindow(sessionId, title) from $lib/stores/windows Stable
Dashboard summary summary, subscribeContext from $lib/stores/context Stable
Live events subscribeEvents from $lib/stores/events Stable
Per-session chat chatFor(sessionId) from $lib/stores/chat Stable
Per-session workspace workspaceFor(sessionId) from $lib/stores/workspace Stable
REST API $lib/api functions Stable (generated from OpenAPI)
UI primitives $lib/components/ui/* Stable
Theme getTheme, setTheme from $lib/stores/theme.svelte Stable

2.5 Content resolution — fixed

Replace the positional if/else chain with a prefix → component map owned by the shell:

// WindowLayer.svelte — one map, dispatch by prefix. New window kinds
// register here, not in an if/else chain.
const CONTENT_RESOLVERS: Array<[prefix: string, resolve: (id: string) => Component | null]> = [
  ['session:', () => SessionChatWindow],
  ['app:',     (id) => appById.get(id.slice(4))?.component ?? null],
]

function resolveContent(id: string): Component | null {
  if (id === NEW_TASK_WINDOW_ID) return NewTaskChat
  for (const [prefix, resolve] of CONTENT_RESOLVERS) {
    if (id.startsWith(prefix)) return resolve(id)
  }
  return EntityDetailContent   // bare entity slug fallback
}

Adding a 'lxc:' console window kind later = one array entry. The existing orphan-close effect (WindowLayer.svelte:25-30) is kept as-is; Phase 2 must gate it on registry-ready (§5).

2.6 Designed extension points (documented, not built)

Extension Mechanism when built Trigger
Titlebar actions titlebarActions?: Component on AppDef, rendered left of min/max/close First app that needs one
App-scoped state state?: () => Record<string, unknown> on AppDef First app with cross-mount state that isn't module-scoped
onRegister handshake Called with a scoped AppOS capability object Phase 3 (dynamic install)

Documenting these now prevents the Phase 1 contract from painting itself into a corner; building them now would be speculative.


3. The mascot as an App

3.1 Registration

{
  id: 'mascot',
  title: 'Cluck',
  icon: EggIcon,                 // Lucide egg (chick/adult swap is a future nicety)
  component: MascotLayer,
  docked: true,
  // no width/height — docked
  // no badge — a permanent "1" is noise, not information
}

3.2 The docked-visibility store (new)

// lib/stores/docked.ts
// Visibility for docked apps — persisted, so "hidden" survives reloads.
// Keyed by app id; absent key = visible (default-on for new docked apps).
export const dockedVisibility: Readable<Record<string, boolean>>
export function toggleDocked(appId: string): void
export function isDockedVisible(appId: string): boolean
  • localStorage key: oikos-docked-apps
  • Default: visible (a fresh install shows the mascot; hiding is opt-out)
  • Merge semantics mirror icons.ts: unknown persisted keys are kept (an uninstalled docked app that gets reinstalled remembers its state)

3.3 Shell changes

windows.tsopenAppWindow branches on kind:

export function openAppWindow(appId: string): void {
  const app = appById.get(appId)
  if (!app) return
  if (app.docked) { toggleDocked(appId); return }   // ← the branch the first draft missed
  // ... existing wm.open path unchanged
}

This is the load-bearing detail: the icon click in Desktop.svelte:93 calls openAppWindow(app.id) for every app uniformly. Branching inside openAppWindow means Desktop.svelte, legacy hash resolution, and any future caller need no special cases.

Desktop.svelte — replace hardcoded <MascotLayer /> with:

<DockedLayer />

components/desktop-shell/DockedLayer.svelte — new, ~30 lines:

{#each APPS.filter(a => a.docked) as app (app.id)}
  {#if $dockedVisibility[app.id] ?? true}
    <app.component />
  {/if}
{/each}

Rendered after <WindowLayer /> inside the surface div, so docked apps share the surface's coordinate space (the mascot's ground-line computation depends on this — MascotLayer.svelte:9-13).

MascotLayer.svelte — zero changes. No props today, no props after.

3.4 What the mascot gains

Feature Before After
Registry entry None — hardcoded in shell First-class AppDef
Show/hide Impossible — always mounted Icon click toggles; persists across reloads
Shell coupling Desktop.svelte imports mascot internals Shell knows only AppDef
Precedent for overlay apps None Any docked: true app (clock, net monitor) uses the same path

3.5 What the mascot does not gain (deliberately)

  • No taskbar button. No window → no taskbar entry. The desktop icon is the control.
  • No window chrome. It's a desktop creature, not a document.
  • No settings panel in v1. Hatch/rename/pet/feed stay in the existing radial menu. A mascot settings surface (volume, behavior toggles) would be a separate windowed app later — noted as a follow-up idea, not planned.

3.6 UX risk: "where did my chicken go?"

Hidden state persists across reloads. Mitigation: the desktop icon is always present and is the obvious toggle; the icon's tooltip reads "Cluck — click to show/hide". Acceptable.


4. Current apps — conformance audit

App Conforms? Notes
Tasks (Overview.svelte) Full Self-contained. Opens session windows via openTaskWindow.
Knowledge Base (KnowledgeBase.svelte) Full Opens entity windows via openEntityWindow.
Operations (Ops.svelte) Full Badge reads summary.
Signals (Signals.svelte) Full Opens entity windows.
Knowledge (Knowledge.svelte) Full
Learning (Learning.svelte) Full
Settings (Settings.svelte) Full Opened from taskbar tray too — same openAppWindow path.
Mascot Not an App Hardcoded in Desktop.svelte. Refactored per §3.

All seven windowed apps conform today. "Independently shippable" at Phase 1 means: add = one page file + one registry entry; remove = delete both. No shell edits, no inter-app imports (apps open each other's surfaces only through AppOS primitives).


5. Extensibility roadmap

Phase 1: Strengthen the contract (this plan)

  • AppDef extended: docked, noIcon; geometry conditional on kind
  • lib/stores/docked.ts: docked-visibility store, persisted
  • openAppWindow branches on docked
  • DockedLayer.svelte: generic docked-app layer in Desktop.svelte
  • Mascot registered as docked: true; hardcoded <MascotLayer /> removed
  • WindowLayer: prefix-map content resolution (deferred — re-audited as gold-plating; original gate already handles orphans)
  • apps.test.ts: validation rules per kind (§2.3)
  • AppOS contract documented (§2.4 lands in MBSE component doc)

Phase 2: Lazy loading

  • component becomes () => Promise<{ default: Component }>; all apps use dynamic imports
  • Desktop icons render immediately (metadata only); component chunk loads on window open
  • LazyApp.svelte — shared loading skeleton (spinner) used by WindowLayer + DockedLayer
  • Deleted LazyMascot.svelte — the registry lazy loader breaks the cycle directly
  • Vite code-splits each app into its own chunk (main bundle 800KB → 482KB)
  • Icon store revalidates against live registry (Phase 3 prerequisite — not needed while apps are statically registered)
  • WindowLayer orphan-close gated on registry-ready (Phase 3 prerequisite)

Phase 3: Dynamic app installation (frontend scaffold, local bundles)

Scoped at execution time to local bundles only (remote-URL loading + sandboxing deferred to Phase 4 — security-critical, needs ADR + careful design). The mechanism built here generalizes to remote bundles by swapping the catalog for a fetched manifest + import(/* @vite-ignore */ url).

  • AppManifest format (id, title, permissions, version, geometry) — web/src/app-store/catalog.ts
  • AppPermission enum (declaration-only; enforcement is Phase 4)
  • Static catalog with one demo app (Notes) — web/src/app-store/apps/Notes.svelte
  • Runtime registry: APPS → derived store (built-ins + installed); appById → derived Map
  • installApp / uninstallApp + localStorage persistence (oikos-installed-apps)
  • icons.ts reactive to app registration (late-registering apps get free cells; reset re-seeds from live registry)
  • WindowLayer orphan-close reactive to $appById (reinstall revives, uninstall closes)
  • App Store page (web/src/pages/AppStore.svelte) — list / install / uninstall
  • Installed apps appear on desktop immediately (no reload); uninstall removes icon + closes window
  • Icon store revalidates against live registry (the Phase 3 prerequisite — now done)
  • /api/v1/apps endpoint + DB-backed manifest storage (Phase 4)
  • Remote bundle loading from URLs + CSP + capability sandboxing (Phase 4)
  • Permission enforcement at AppOS boundary (Phase 4)

Phase 4: Marketplace (vision)

  • Community apps (network map, backup dashboard, energy monitor)
  • Versioning + auto-update
  • Mascot skin packs as installable docked-app variants

6. Implementation — Phase 1, file by file

# File Change
1 lib/apps.ts Extend AppDef (docked?, noIcon?, geometry optional). Register mascot. Import MascotLayer + EggIcon.
2 lib/stores/docked.ts New. dockedVisibility store, toggleDocked, isDockedVisible, localStorage persistence.
3 lib/stores/windows.ts openAppWindow: docked branch → toggleDocked.
4 components/desktop-shell/DockedLayer.svelte New. Renders visible docked apps after WindowLayer.
5 components/desktop-shell/Desktop.svelte Replace import MascotLayer + <MascotLayer /> with <DockedLayer />.
6 components/desktop-shell/WindowLayer.svelte Deferred during implementation. The positional if/else was re-audited and found to already handle orphans cleanly (`{#if win && (!appId
7 lib/apps.test.ts Mock MascotLayer import (same pattern as pages). Per-kind validation tests. Docked apps exempt from positive-size test.
8 lib/stores/docked.test.ts New. Toggle, persistence, default-visible, unknown-key merge.
9 docs/mbse/components.md Add Component 9: Web Control Room — App Architecture (§7).

Out of scope for Phase 1: titlebarActions, app-scoped state, lazy loading, manifests, permissions.

Verification:

cd web
npm run test          # vitest — registry + docked store
npm run check         # svelte-check + tsc
npm run lint
npm run build         # vite build — confirms no import cycles from DockedLayer

Manual smoke: icon toggle hides/shows mascot → reload → stays hidden → toggle → returns at last position (model lastPos restore). All seven windowed apps open/focus/close identically to before. Legacy hash #/signals still opens the Signals window.


7. MBSE documentation

Add Component 9: Web Control Room — App Architecture to docs/mbse/components.md:

9. Web Control Room — App Architecture
  9.1 Purpose — OS + Apps metaphor, why apps are independently shippable
  9.2 Structural View — shell modules, registry, docked layer (mermaid)
  9.3 App Contract — AppDef, validation rules, app kinds
  9.4 OS-Service Surface — the AppOS table
  9.5 Content Resolution — prefix map, window kinds, orphan cleanup
  9.6 Behavior — window state machine, docked visibility lifecycle
  9.7 Requirements — WEB-APP-* traceability
  9.8 Verification — test coverage, manual smoke

Requirements

ID Requirement Status
WEB-APP-1 Apps register via data-driven AppDef entries; no shell edits to add/remove live
WEB-APP-2 Apps render in wmkit floating windows live
WEB-APP-3 Window IDs namespaced (app:/session:/entity) — no collisions live
WEB-APP-4 Desktop icons render from the registry live
WEB-APP-5 Taskbar buttons derive from window state, icons resolved via registry live
WEB-APP-6 Removed apps' persisted windows self-close live (WindowLayer.svelte:25-30)
WEB-APP-7 Content resolution dispatches via prefix map, not positional if/else Deferred — re-audited; original gate already handles orphans, map adds no decoupling (§2.5)
WEB-APP-8 Docked app kind: no window, no chrome, visibility toggled via icon Phase 1
WEB-APP-9 Mascot is a registered docked App, not a hardcoded shell component Phase 1
WEB-APP-10 Docked visibility persists across reloads Phase 1
WEB-APP-11 OS-service surface (AppOS) documented as the stable App API Phase 1
WEB-APP-12 Registry validation: per-kind geometry rules enforced by tests Phase 1
WEB-APP-13 Apps lazy-load; icons render from static metadata Phase 2
WEB-APP-14 Icon store revalidates against live registry, not import-time snapshot Phase 3
WEB-APP-15 Third-party apps install from manifests with declared permissions Phase 3 (local bundles; enforcement Phase 4)

Sequence — windowed app open

sequenceDiagram
    participant User
    participant Desktop
    participant WM as Window Manager
    participant WL as Window Layer
    participant App

    User->>Desktop: click icon
    Desktop->>WM: openAppWindow("signals")
    Note over WM: docked? no → wm path
    alt window exists
        WM->>WM: restore + focus
    else new
        WM->>WM: wm.open({ id: "app:signals", ... })
        WM->>WL: render frame
        WL->>WL: resolveContent → prefix 'app:' → registry
        WL->>App: mount component
    end
    WM->>Taskbar: new button in wmState.order

Sequence — docked app toggle

sequenceDiagram
    participant User
    participant Desktop
    participant Dock as docked.ts
    participant Layer as DockedLayer
    participant App

    User->>Desktop: click Cluck icon
    Desktop->>Dock: openAppWindow("mascot") → docked → toggleDocked
    Dock->>Dock: flip visibility, persist localStorage
    Dock->>Layer: store update
    alt now visible
        Layer->>App: mount MascotLayer
        Note over App: model + sprites restore<br/>from module scope
    else now hidden
        Layer->>App: unmount (state survives)
    end

State machine — app window

stateDiagram-v2
    [*] --> Closed: registered, no window
    Closed --> Open: openAppWindow
    Open --> Focused: focus
    Focused --> Open: blur
    Open --> Minimized: minimize
    Minimized --> Focused: restore
    Open --> Closed: close
    Minimized --> Closed: close

8. Risk & safety

Risk Severity Mitigation
Mascot refactor breaks stimuli or ground-line computation Medium MascotLayer unchanged; DockedLayer mounts it in the same surface div, same position in the stacking order as today.
Hidden mascot never rediscovered Low Icon always present, tooltip says show/hide.
openAppWindow docked branch leaks into windowed path Low Branch is the first statement; windowed path byte-identical. Covered by existing call sites (icon click, taskbar settings, legacy hash).
Docked visibility store desyncs from registry Low Unknown keys kept on load; layer filters by a.docked from the live registry.
Phase 2 lazy loading kills persisted windows of not-yet-loaded apps Medium Explicit Phase 2 gate: orphan-close waits for registry-ready (§5). Called out now so it isn't discovered in production.

9. Appendix: relevant existing artifacts

Artifact Relevance
docs/mbse/README.md §5 MCP tools / REST / SSE — the data surface Apps consume
docs/mbse/components.md §5 Current web control room component doc — Phase 1 extends it
docs/mascot/README.md Mascot subsystem model (MASC-1..12); MASC-9's registry philosophy is the template for this plan
plans/2026-07-08-control-room-webui.md Original control-room plan
plans/done/2026-07-11-ui-review-ia-usability.md IA review that produced the desktop metaphor
plans/2026-07-20-desktop-mascot.md Mascot plan; extension registries
lib/apps.ts header comment Already documents the one-entry-to-add-an-app philosophy

Plan opened 2026-07-21. Phase 1 ready for execution — estimated small (~half a day of focused work; nine file touches, two new files). Phases 24 are context for future sessions and do not block Phase 1.