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

648 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
24 are context for future sessions and do not block Phase 1.*