# 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/` 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:` (`apps.ts:122`) — distinct from `session:`, `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 `` 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}{/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)} {:else if id === NEW_TASK_WINDOW_ID} {:else if app} {:else} {/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:" 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` 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> 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 `` with:** ```svelte ``` **`components/desktop-shell/DockedLayer.svelte` — new, ~30 lines:** ```svelte {#each APPS.filter(a => a.docked) as app (app.id)} {#if $dockedVisibility[app.id] ?? true} {/if} {/each} ``` Rendered after `` 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 `` 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` + `` with ``. | | 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
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.*