# 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 list** — `Desktop.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:

```svelte
{#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

```typescript
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:

```typescript
// 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

```typescript
{
  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)

```typescript
// 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.ts` — `openAppWindow` branches on kind:**

```typescript
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:**

```svelte
<DockedLayer />
```

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

```svelte
{#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)

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

### Phase 2: Lazy loading

- [x] `component` becomes `() => Promise<{ default: Component }>`; all apps use dynamic imports
- [x] Desktop icons render immediately (metadata only); component chunk loads on window open
- [x] `LazyApp.svelte` — shared loading skeleton (spinner) used by WindowLayer + DockedLayer
- [x] Deleted `LazyMascot.svelte` — the registry lazy loader breaks the cycle directly
- [x] 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)`.

- [x] `AppManifest` format (id, title, permissions, version, geometry) — `web/src/app-store/catalog.ts`
- [x] `AppPermission` enum (declaration-only; enforcement is Phase 4)
- [x] Static catalog with one demo app (Notes) — `web/src/app-store/apps/Notes.svelte`
- [x] Runtime registry: `APPS` → derived store (built-ins + installed); `appById` → derived Map
- [x] `installApp` / `uninstallApp` + localStorage persistence (`oikos-installed-apps`)
- [x] `icons.ts` reactive to app registration (late-registering apps get free cells; reset re-seeds from live registry)
- [x] WindowLayer orphan-close reactive to `$appById` (reinstall revives, uninstall closes)
- [x] App Store page (`web/src/pages/AppStore.svelte`) — list / install / uninstall
- [x] Installed apps appear on desktop immediately (no reload); uninstall removes icon + closes window
- [x] 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 || app)}`), and any new window kind needs a prop-dispatch branch in markup regardless — so a prefix→component map adds machinery without decoupling. Documented as an extension point (§2.5) like `titlebarActions`; not built (YAGNI). |
| 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:**

```bash
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

```mermaid
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

```mermaid
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

```mermaid
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
2–4 are context for future sessions and do not block Phase 1.*
