diff --git a/VERSION b/VERSION
index d9df1bb..ac454c6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.11.0
+0.12.0
diff --git a/docs/mbse/components.md b/docs/mbse/components.md
index da6cd73..14eac8a 100644
--- a/docs/mbse/components.md
+++ b/docs/mbse/components.md
@@ -31,6 +31,7 @@ the relevant section here.
| [6. PostgreSQL/TimescaleDB](#6-postgresqltimescaledb) | `migrations/`, `seeds/` | β live β the System's own source of truth |
| [7. Dormant components](#7-dormant-components) | `internal/actuator`, `internal/learning` | π΄ compiled, never started |
| [8. Auxiliary components](#8-auxiliary-components) | `cmd/webhook`, `cmd/desktop` | β live β deploy + packaging, not decision logic |
+| [9. web control room β App architecture](#9-web-control-room--app-architecture) | `web/src/lib/apps.ts`, `web/src/lib/stores/windows.ts`, `web/src/lib/stores/docked.ts`, `web/src/lib/components/desktop-shell/` | β live β the OS + Apps shell contract |
---
@@ -364,7 +365,11 @@ cross-origin (the Wails desktop webview, Β§8).
Standalone deploy, versioned and released independently of the `oikos`
binary β see [README.md Β§4.5](README.md#45-build--release-artifacts) for
why "deployed" means two different release cadences depending on whether
-you mean the container or the desktop app.
+you mean the container or the desktop app. The shell-level architecture
+(window manager, app registry, docked layer) is documented separately as
+[Β§9 below](#9-web-control-room--app-architecture); this section covers
+the page-level concerns, Β§9 covers the OS + Apps contract the pages hang
+off.
---
@@ -496,6 +501,177 @@ functional sense.
---
+## 9. web control room β App architecture
+
+**Stakeholders:** anyone adding a page, adding a desktop overlay, or
+planning dynamic/third-party app installation. **Why this View earns its
+place:** Β§5 documents the *pages*; this View documents the *shell* they
+hang off β and the shell is the part whose contract a new app has to
+satisfy. It is also the layer where the "Oikos-as-OS" metaphor
+(desktop, icons, floating windows, a tamagotchi-style resident
+creature) is actually implemented, so the boundary between "Base OS" and
+"App" has to be explicit here or it doesn't exist anywhere.
+
+### App architecture β Internal structure
+
+| File | Role |
+|---|---|
+| `web/src/lib/apps.ts` | The App registry. Two layers: `builtinApps` (static, always installed) + `installedAppIds` (persisted, from the App Store). The public `apps` store is derived (built-in + installed); `appById` is a derived Map. `installApp`/`uninstallApp` mutate the installed set. Window-id helpers (`appWindowId`, `appIdFromWindowId`) unchanged. |
+| `web/src/app-store/catalog.ts` | The installable-app catalog: `AppManifest` (persistable metadata) + `CatalogEntry` (manifest + Lucide icon + dynamic-import loader). Static in Phase 3 (apps ship with the build); Phase 4 swaps this for a fetched `/api/v1/apps` endpoint. Declares `AppPermission` (enforcement is Phase 4). |
+| `web/src/app-store/apps/Notes.svelte` | Demo installable app β a localStorage-backed scratchpad proving the installβiconβwindowβuninstall lifecycle end-to-end. |
+| `web/src/lib/stores/windows.ts` | The wmkit window manager singleton + the `openAppWindow` / `openEntityWindow` / `openTaskWindow` primitives. `openAppWindow` branches on `docked` (toggles visibility) vs windowed (`wm.open`); resolves the app via `get(appById)`. |
+| `web/src/lib/stores/docked.ts` | Persisted visibility for docked apps. Absent key = visible (default-on); store holds only overrides. Deliberately does **not** import `APPS` β doing so would create a static cycle (`apps.ts` β pages β `windows.ts` β here β `apps.ts`) and fire a TDZ on `APPS` at init. |
+| `web/src/lib/stores/icons.ts` | Desktop icon grid: column/row positions, drag-to-reorder, localStorage persistence. Reactive to the `apps` store β a newly-installed app gets a free cell on the next emission; `resetIconLayout` re-seeds from the live registry, not a static snapshot. |
+| `web/src/lib/components/LazyApp.svelte` | Renders an app's lazily-loaded component (`AppDef.component` is a dynamic-import loader, not the component). Shows the shared spinner while the chunk fetches; used by both WindowLayer and DockedLayer so the loading state is uniform across app kinds. Vite's module cache makes repeat opens resolve from cache. |
+| `web/src/lib/components/desktop-shell/Desktop.svelte` | Full-viewport surface: background, icons, task launcher, ``, ``, taskbar. Reads `$apps` (the derived store) so installs reflect immediately. |
+| `web/src/lib/components/desktop-shell/WindowLayer.svelte` | Floating-window stack (z-40). Resolves window id β content component; renders shared titlebar chrome. The orphan-close `$effect` is reactive on `$appById` β reinstalling an app revives its persisted window, uninstalling closes it. |
+| `web/src/lib/components/desktop-shell/DockedLayer.svelte` | Docked-app overlay (z-45). Renders `$apps.filter(a => a.docked)` gated on `dockedVisibility`. Replaces the previously-hardcoded ``. |
+| `web/src/lib/components/desktop-shell/Taskbar.svelte` | Window buttons + tray. Renders from `wmState.order`; resolves icons via `$appById`. |
+| `web/src/pages/AppStore.svelte` | The App Store β lists the catalog, shows install state, install/uninstall. Installing makes the app appear on the desktop immediately (no reload) via the reactive `apps` store; uninstalling closes any open window for that app via WindowLayer's orphan-close effect. |
+
+### App architecture β The App contract
+
+```typescript
+interface AppDef {
+ id: string // unique; window IDs are "app:"
+ title: string // desktop icon label + window titlebar
+ icon: Component // Lucide icon (desktop icon + taskbar)
+ component: () => Promise<{ default: Component }> // dynamic-import loader
+ docked?: boolean // true = Docked Layer app, no window
+ noIcon?: boolean // true = registered but no desktop icon
+ width?: number; height?: number; minWidth?: number; minHeight?: number
+ // required for windowed, forbidden for docked
+ badge?: (s: DashboardSummary | null) => number
+}
+```
+
+`component` is a dynamic-import loader (`() => import('../pages/X.svelte')`),
+not the component itself. Desktop icons render from metadata alone (id,
+title, icon β all static), the component chunk fetches on first window
+open, and Vite code-splits each app into its own chunk (Phase 2). The
+mascot uses the same path β `() => import('./mascot/MascotLayer.svelte')`
+β which also defers the mascot's module graph until after `apps.ts` has
+finished initializing, breaking what would otherwise be a static cycle
+(`apps.ts` β `MascotLayer` β `Mascot.svelte` β `icons.ts` β `apps.ts`).
+
+Two app kinds, picked by one flag:
+
+| Kind | Window | Titlebar | Taskbar | Opened by |
+|---|---|---|---|---|
+| **Windowed** (default) | wmkit floating window | yes | yes | `openAppWindow` β `wm.open` |
+| **Docked** (`docked: true`) | none β renders on the Docked Layer | no | no | `openAppWindow` β `toggleDocked` |
+
+Apps receive **no props** from the shell. They import the OS-service
+surface (below) directly. The shellβapp edge is one-way.
+
+### App architecture β 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** the moment third-party app installation
+(Phase 3 in [the plan](../../plans/2026-07-21-frontend-os-apps-architecture.md)) lands.
+
+| Service | Import |
+|---|---|
+| Open an app window | `openAppWindow(id)` from `$lib/stores/windows` |
+| Open an entity window | `openEntityWindow(slug)` from `$lib/stores/windows` |
+| Open a task window | `openTaskWindow(sessionId, title)` from `$lib/stores/windows` |
+| Dashboard summary | `summary`, `subscribeContext` from `$lib/stores/context` |
+| Live events | `subscribeEvents` from `$lib/stores/events` |
+| Per-session chat / workspace / activity | `chatFor`, `workspaceFor`, `activityLogFor` from `$lib/stores/{chat,workspace,activity}` |
+| REST API | `$lib/api` (generated from OpenAPI, [ADR-0004](../adr/0004-openapi-first.md)) |
+| UI primitives | `$lib/components/ui/*` |
+| Theme | `getTheme`, `setTheme` from `$lib/stores/theme.svelte` |
+
+### App architecture β Content resolution
+
+Window ids are namespaced so the window layer resolves content purely
+from the id, with no extra bookkeeping β which is also why persisted
+windows hydrate correctly across reloads:
+
+| Id shape | Renders |
+|---|---|
+| `app:` | the registry app's component (`appById.get(id).component`) |
+| `session:` | `SessionChatWindow` (per-session chat) |
+| `new-task` | `NewTaskChat` (singleton compose) |
+| bare slug (`type:identifier`) | `EntityDetailContent` (fallback) |
+
+A hydrated `app:` window whose id no longer matches a registry entry
+(an app removed since the layout was persisted) self-closes β the
+orphan-close `$effect` in `WindowLayer.svelte` sweeps it on mount.
+
+### App architecture β Current population
+
+Seven windowed apps + one docked app:
+
+| App | Kind | Badge |
+|---|---|---|
+| `tasks` | windowed | β |
+| `kb` | windowed | β |
+| `ops` | windowed | `approvals_pending` |
+| `signals` | windowed | open signal count |
+| `knowledge` | windowed | β |
+| `learning` | windowed | β |
+| `settings` | windowed | β |
+| `mascot` (Cluck) | **docked** | β |
+
+The mascot is the first docked app and the reason the docked kind
+exists; before this View it was a hardcoded `` in
+`Desktop.svelte`, not a registry entry. Its persistent model
+(`web/src/lib/mascot/state.svelte.ts`, localStorage) and sprite cache
+(`sprites.ts`) are module-scoped, so toggling visibility (unmount) and
+restoring (remount) loses no state β this is why `docked` visibility is
+a plain `{#if}` gate rather than a `keepAlive` mechanism.
+
+### App architecture β 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) |
+| Third-party manifests | `AppManifest` JSON + `/api/v1/apps` + permission model | Phase 3 |
+
+Documenting these now prevents the current contract from painting itself
+into a corner; building them now would be speculative. (Lazy-loaded
+components were on this list and shipped in Phase 2 β `component` is now
+`() => Promise<{ default: Component }>` and Vite code-splits each app.)
+
+### App architecture β Status and known issues
+
+Phase 1 (the docked kind, mascot-as-app, the docked visibility store) and
+Phase 2 (lazy component loading β `component` as dynamic-import loader,
+`LazyApp.svelte` for uniform loading state, per-app code-splitting) have
+landed. Open items, by phase:
+
+- **Phase 3 (dynamic install):** the AppOS table above becomes a real
+ injected capability object, not a documentation table; permissions
+ enforced at the store-access boundary; `AppManifest` format +
+ `/api/v1/apps` endpoint + install flow.
+- **Late-registering apps (Phase 3 prerequisite):** `icons.ts:48` builds
+ `appIds` once at module load to validate persisted positions β fine
+ today (all apps are in the static `APPS` array; only their components
+ are lazy), fragile the moment apps register post-load. When dynamic
+ registration lands, revalidate against the live registry, not the
+ import-time snapshot. Likewise `WindowLayer`'s orphan-close `$effect`
+ must be gated on registry-ready so a not-yet-loaded app's persisted
+ window isn't killed on hydration.
+
+The static-cycle trap that bit this View during Phase 1 implementation is
+now resolved by Phase 2's lazy loading β recording it for context:
+
+- `apps.ts` no longer statically imports any page or the mascot (they're
+ all `() => import(...)`), so there's no static edge from `apps.ts` into
+ the mascot/page module graph to cycle through `icons.ts` back to `APPS`.
+ The earlier `LazyMascot.svelte` wrapper (Phase 1's cycle break) was
+ deleted in Phase 2 β the lazy loader in the registry replaces it.
+ `docked.ts` still must not import `APPS` (it's reached from `apps.ts`'s
+ graph via `windows.ts`), and doesn't β defaults are implicit
+ (absent key = visible).
+
+---
+
## Keeping this document current
The same discipline as README.md's closing note applies here, scoped to
diff --git a/plans/2026-07-21-frontend-os-apps-architecture.md b/plans/2026-07-21-frontend-os-apps-architecture.md
new file mode 100644
index 0000000..1cfe68a
--- /dev/null
+++ b/plans/2026-07-21-frontend-os-apps-architecture.md
@@ -0,0 +1,647 @@
+# 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.*
diff --git a/plans/index.md b/plans/index.md
index 43726ce..9400733 100644
--- a/plans/index.md
+++ b/plans/index.md
@@ -21,6 +21,7 @@ went sideways, open an investigation.
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Implemented in v0.8.0 β see deviation note; physics/window-interaction follow-ups tracked separately |
| 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 β all P0/P1/P2 items landed |
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0βP2 implemented; P3 ("cool stuff") ideas open |
+| 2026-07-21 | [Frontend as OS + Apps β architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned β Phase 1 ready |
## Done
diff --git a/web/src/lib/app-store/apps/Notes.svelte b/web/src/lib/app-store/apps/Notes.svelte
new file mode 100644
index 0000000..5884a5d
--- /dev/null
+++ b/web/src/lib/app-store/apps/Notes.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+
Notes
+
+ {#if saved}saved{:else}unsaved{/if}
+
+
+
+
+ A demo installable app β uninstall it from the App Store to remove its
+ icon and window. Its notes persist in localStorage under
+ {storageKey}.
+
+
diff --git a/web/src/lib/app-store/catalog.ts b/web/src/lib/app-store/catalog.ts
new file mode 100644
index 0000000..dc26996
--- /dev/null
+++ b/web/src/lib/app-store/catalog.ts
@@ -0,0 +1,81 @@
+// App Store β installable app catalog + manifest format.
+//
+// This is Phase 3's "frontend scaffold, local bundles only" path: a static
+// catalog of apps that ship with the build, each described by a persistable
+// manifest (metadata) and resolved at runtime to a loader + icon (runtime
+// bits that are NOT persisted β they're looked up from the catalog by
+// manifest id on load). Installing an app = persisting its manifest id;
+// uninstalling = removing it. The mechanism generalizes to remote bundles
+// in Phase 4 by swapping the catalog for a fetched manifest + a
+// `import(/* @vite-ignore */ entryUrl)` loader.
+//
+// Permissions are DECLARED on the manifest but NOT YET ENFORCED β that's
+// Phase 4 (sandboxing). They're part of the contract now so a manifest
+// author has to name what the app needs, and the operator can see it in
+// the App Store before installing. Enforcement will land at the AppOS
+// boundary (docs/mbse/components.md Β§9 "OS-service surface") in Phase 4.
+import type { Component } from 'svelte'
+import NotesIcon from '@lucide/svelte/icons/sticky-note'
+
+// A permission an installable app can request. Maps 1:1 to entries in the
+// AppOS table (docs/mbse/components.md Β§9). Phase 4 will enforce these at
+// the store-access boundary; today they're declaration-only.
+export type AppPermission =
+ | 'open-window' // openAppWindow / openEntityWindow / openTaskWindow
+ | 'read-context' // dashboard summary, subscribeContext
+ | 'read-events' // subscribeEvents (SSE)
+ | 'api:entities' // $lib/api entity endpoints
+ | 'api:knowledge' // knowledge search/content
+ | 'api:executions' // executions/approvals
+ | 'theme' // getTheme / setTheme
+
+// Persistable metadata describing an installable app. This is what's
+// stored in localStorage when an app is installed (just the manifest id is
+// persisted, actually β the manifest is re-resolved from the catalog on
+// load β but the shape is the unit of interchange and will be what a
+// remote `/api/v1/apps` endpoint returns in Phase 4).
+export interface AppManifest {
+ id: string
+ title: string
+ description: string
+ version: string
+ author?: string
+ permissions: AppPermission[]
+ docked?: boolean
+ noIcon?: boolean
+ width?: number
+ height?: number
+ minWidth?: number
+ minHeight?: number
+}
+
+// A catalog entry: the manifest (persistable metadata) plus the runtime
+// bits the catalog resolves by id β the Lucide icon component and the
+// dynamic-import loader. These runtime bits are never persisted; they're
+// re-looked-up from this static catalog on every load.
+export interface CatalogEntry {
+ manifest: AppManifest
+ icon: Component
+ load: () => Promise<{ default: Component }>
+}
+
+export const CATALOG: CatalogEntry[] = [
+ {
+ manifest: {
+ id: 'notes',
+ title: 'Notes',
+ description: 'A scratchpad. Auto-saves to localStorage. Demo installable app.',
+ version: '0.1.0',
+ author: 'oikos',
+ permissions: ['theme'],
+ width: 640,
+ height: 480,
+ minWidth: 360,
+ minHeight: 320
+ },
+ icon: NotesIcon,
+ load: () => import('./apps/Notes.svelte')
+ }
+]
+
+export const catalogById = new Map(CATALOG.map((e) => [e.manifest.id, e]))
diff --git a/web/src/lib/apps.test.ts b/web/src/lib/apps.test.ts
index aab0b53..e363d3f 100644
--- a/web/src/lib/apps.test.ts
+++ b/web/src/lib/apps.test.ts
@@ -1,46 +1,56 @@
-import { describe, it, expect, vi } from 'vitest'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
-// apps.ts wires in every page component for real use, but that drags a
-// heavy transitive graph into a unit test for no benefit here (and one of
-// those pages imports svelte-sonner, which fails to resolve under vitest's
-// bundled Vite β an unrelated, pre-existing package quirk). These tests only
-// care about the registry's own shape (ids, sizes, window-id helpers), so
-// stub the component imports out rather than pull all of that in.
-vi.mock('../pages/Overview.svelte', () => ({ default: {} }))
-vi.mock('../pages/KnowledgeBase.svelte', () => ({ default: {} }))
-vi.mock('../pages/Ops.svelte', () => ({ default: {} }))
-vi.mock('../pages/Signals.svelte', () => ({ default: {} }))
-vi.mock('../pages/Knowledge.svelte', () => ({ default: {} }))
-vi.mock('../pages/Learning.svelte', () => ({ default: {} }))
+// apps.ts holds only app metadata + a reactive registry. `component` is a
+// dynamic-import loader, not the page itself, so importing apps.ts pulls no
+// page modules. The install/uninstall tests touch localStorage and the
+// module-scoped installedIds store, so each re-imports the module fresh (see
+// docked.test.ts for the same pattern).
+import { builtinApps, appWindowId, appIdFromWindowId } from './apps'
-import { APPS, appById, appWindowId, appIdFromWindowId } from './apps'
-
-describe('APPS registry', () => {
+describe('builtinApps registry', () => {
it('has unique, non-empty ids', () => {
- const ids = APPS.map((a) => a.id)
+ const ids = builtinApps.map((a) => a.id)
expect(ids.length).toBeGreaterThan(0)
expect(new Set(ids).size).toBe(ids.length)
for (const id of ids) expect(id).not.toBe('')
})
- it('gives every app a positive default size', () => {
- for (const app of APPS) {
+ it('component is a loader function, not the component itself', () => {
+ for (const app of builtinApps) {
+ expect(typeof app.component).toBe('function')
+ }
+ })
+
+ it('every built-in is source: builtin', () => {
+ for (const app of builtinApps) expect(app.source).toBe('builtin')
+ })
+
+ it('windowed apps have positive default geometry', () => {
+ for (const app of builtinApps.filter((a) => !a.docked)) {
expect(app.width).toBeGreaterThan(0)
expect(app.height).toBeGreaterThan(0)
}
})
- it('is indexed by id in appById', () => {
- for (const app of APPS) {
- expect(appById.get(app.id)).toBe(app)
+ it('docked apps forbid window geometry', () => {
+ for (const app of builtinApps.filter((a) => a.docked)) {
+ expect(app.width).toBeUndefined()
+ expect(app.height).toBeUndefined()
+ expect(app.minWidth).toBeUndefined()
+ expect(app.minHeight).toBeUndefined()
}
- expect(appById.size).toBe(APPS.length)
+ })
+
+ it('includes the App Store and mascot as built-ins', () => {
+ expect(builtinApps.find((a) => a.id === 'app-store')).toBeTruthy()
+ const mascot = builtinApps.find((a) => a.id === 'mascot')
+ expect(mascot?.docked).toBe(true)
})
})
describe('appWindowId / appIdFromWindowId', () => {
it('round-trips an app id through its window id', () => {
- for (const app of APPS) {
+ for (const app of builtinApps) {
expect(appIdFromWindowId(appWindowId(app.id))).toBe(app.id)
}
})
@@ -52,10 +62,74 @@ describe('appWindowId / appIdFromWindowId', () => {
})
it('namespaces window ids so they cannot collide with entity slugs', () => {
- // Entity slugs are bare `type:identifier` strings (see windows.ts's
- // openEntityWindow) β app window ids must never look like one.
- for (const app of APPS) {
+ for (const app of builtinApps) {
expect(appWindowId(app.id).startsWith('app:')).toBe(true)
}
})
})
+
+// install/uninstall lifecycle β each test re-imports fresh so the
+// module-scoped installedIds store starts empty and localStorage is clean.
+describe('install / uninstall', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ vi.resetModules()
+ })
+
+ it('installApp adds a catalog app to the installed set', async () => {
+ const fresh = await import('./apps')
+ fresh.installApp('notes')
+ let snap: string[] = []
+ const unsub = fresh.installedAppIds.subscribe((v) => (snap = v))
+ expect(snap).toContain('notes')
+ unsub()
+ })
+
+ it('install is idempotent', async () => {
+ const fresh = await import('./apps')
+ fresh.installApp('notes')
+ fresh.installApp('notes')
+ let snap: string[] = []
+ fresh.installedAppIds.subscribe((v) => (snap = v))
+ expect(snap.filter((id) => id === 'notes')).toHaveLength(1)
+ })
+
+ it('installing an unknown manifest id is a no-op', async () => {
+ const fresh = await import('./apps')
+ fresh.installApp('does-not-exist')
+ let snap: string[] = []
+ fresh.installedAppIds.subscribe((v) => (snap = v))
+ expect(snap).not.toContain('does-not-exist')
+ })
+
+ it('uninstall removes the app', async () => {
+ const fresh = await import('./apps')
+ fresh.installApp('notes')
+ fresh.uninstallApp('notes')
+ let snap: string[] = []
+ fresh.installedAppIds.subscribe((v) => (snap = v))
+ expect(snap).not.toContain('notes')
+ })
+
+ it('uninstall is idempotent', async () => {
+ const fresh = await import('./apps')
+ expect(() => fresh.uninstallApp('notes')).not.toThrow()
+ })
+
+ it('persists the installed set to localStorage', async () => {
+ const fresh = await import('./apps')
+ fresh.installApp('notes')
+ const raw = localStorage.getItem('oikos-installed-apps')
+ expect(raw).toBeTruthy()
+ expect(JSON.parse(raw!)).toContain('notes')
+ })
+
+ it('drops persisted ids that no longer resolve to a catalog entry', async () => {
+ localStorage.setItem('oikos-installed-apps', JSON.stringify(['notes', 'removed-app']))
+ const fresh = await import('./apps')
+ let snap: string[] = []
+ fresh.installedAppIds.subscribe((v) => (snap = v))
+ expect(snap).toContain('notes')
+ expect(snap).not.toContain('removed-app')
+ })
+})
diff --git a/web/src/lib/apps.ts b/web/src/lib/apps.ts
index 8df7806..86b3020 100644
--- a/web/src/lib/apps.ts
+++ b/web/src/lib/apps.ts
@@ -1,21 +1,29 @@
-// The desktop's app registry β single source of truth for what shows up as
-// a desktop icon and what opens in its window. Adding a new app is one entry
-// here; nothing else needs to change (Desktop.svelte renders icons from
-// APPS, WindowLayer.svelte resolves `app:` window ids back through
-// appById, Taskbar.svelte reads title/icon the same way). Compare to the old
-// App.svelte's hardcoded navItems array + if/else page branch, which required
-// touching three places (nav list, header title, main content branch) to add
-// one page.
+// The app registry β single source of truth for what shows up as a desktop
+// icon and what opens in its window.
+//
+// Two layers:
+// - **Built-in apps** (always installed): the static `builtinApps` array
+// below. These ship with the build and can't be removed.
+// - **Installed apps** (operator-installed from the App Store): persisted
+// manifest ids in localStorage, re-resolved against the catalog at
+// load time. `installApp`/`uninstallApp` mutate this set.
+//
+// The public surface is reactive: `apps` is a derived store (built-in +
+// installed) and `appById` is a derived Map. Consumers (Desktop.svelte,
+// DockedLayer.svelte, Taskbar.svelte, icons.ts, windows.ts) subscribe or
+// use `get()` for synchronous lookups. This is what lets an installed app
+// appear on the desktop the moment it's registered, with no reload.
+//
+// App components are loaded lazily (`component: () => Promise<{ default:
+// Component }>` β a dynamic-import loader). Desktop icons render from
+// metadata alone; the chunk fetches on first window open, and Vite
+// code-splits each app into its own chunk. See
+// docs/mbse/components.md Β§9 for the full contract.
import type { Component } from 'svelte'
+import { writable, derived, get, type Readable } from 'svelte/store'
import type { DashboardSummary } from '$lib/api'
import { openSignalCount } from '$lib/stores/context'
-import Overview from '../pages/Overview.svelte'
-import KnowledgeBase from '../pages/KnowledgeBase.svelte'
-import Ops from '../pages/Ops.svelte'
-import Signals from '../pages/Signals.svelte'
-import Knowledge from '../pages/Knowledge.svelte'
-import Learning from '../pages/Learning.svelte'
-import Settings from '../pages/Settings.svelte'
+import { catalogById, type AppManifest, type AppPermission, type CatalogEntry } from '$lib/app-store/catalog'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import DatabaseIcon from '@lucide/svelte/icons/database'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
@@ -23,98 +31,231 @@ import SirenIcon from '@lucide/svelte/icons/siren'
import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
import SettingsIcon from '@lucide/svelte/icons/settings'
+import EggIcon from '@lucide/svelte/icons/egg'
+import StoreIcon from '@lucide/svelte/icons/store'
+export type { AppManifest, AppPermission }
+
+// Two app kinds, picked by one flag:
+// - Windowed (default): renders in a wmkit floating window. Geometry
+// (width/height/min*) is required.
+// - Docked (docked: true): renders on the Docked Layer above the window
+// layer, with no window chrome and no taskbar button. Clicking its
+// desktop icon toggles visibility (see stores/docked.ts) rather than
+// opening a window. Geometry is forbidden β there is no window to size.
+// Apps receive no props from the shell; they import the OS-service surface
+// ($lib/stores/windows, $lib/stores/context, $lib/api, ...) directly. See
+// docs/mbse/components.md Β§9 for the stable surface contract.
export interface AppDef {
id: string
title: string
icon: Component
- component: Component
- width: number
- height: number
+ // Dynamic-import loader. Invoked when an app window opens (windowed) or
+ // when the Docked Layer first mounts the app (docked). Vite's module cache
+ // makes the second open cheap (promise resolves from cache). The resolved
+ // module is a standard Svelte module namespace β `mod.default` is the
+ // component; LazyApp.svelte unwraps it.
+ component: () => Promise<{ default: Component }>
+ docked?: boolean
+ noIcon?: boolean
+ width?: number
+ height?: number
minWidth?: number
minHeight?: number
- // Pure function over the shared dashboard summary β used for both the
- // desktop icon's badge and the taskbar button's badge, so a new app that
- // wants one just supplies this instead of each surface reimplementing it.
badge?: (summary: DashboardSummary | null) => number
+ // Source β 'builtin' (always installed) or 'installed' (from the App
+ // Store). Used by the App Store UI to distinguish uninstallable apps from
+ // built-ins.
+ source: 'builtin' | 'installed'
}
-export const APPS: AppDef[] = [
+// Built-in apps β always installed, can't be removed. All components use
+// dynamic-import loaders so apps.ts stays out of the page module graph at
+// import time (Phase 2 code-splitting: each page is its own chunk, the
+// main bundle stays small). The mascot uses the same path β deferring its
+// module graph also breaks what would otherwise be a static cycle through
+// icons.ts back to APPS.
+export const builtinApps: AppDef[] = [
{
id: 'tasks',
title: 'Tasks',
icon: ListTodoIcon,
- component: Overview,
+ component: () => import('../pages/Overview.svelte'),
width: 960,
height: 680,
minWidth: 480,
- minHeight: 420
+ minHeight: 420,
+ source: 'builtin'
},
{
id: 'kb',
title: 'Knowledge Base',
icon: DatabaseIcon,
- component: KnowledgeBase,
+ component: () => import('../pages/KnowledgeBase.svelte'),
width: 1000,
height: 700,
minWidth: 520,
- minHeight: 420
+ minHeight: 420,
+ source: 'builtin'
},
{
id: 'ops',
title: 'Operations',
icon: ShieldCheckIcon,
- component: Ops,
+ component: () => import('../pages/Ops.svelte'),
width: 860,
height: 620,
minWidth: 480,
minHeight: 360,
- badge: (s) => s?.approvals_pending ?? 0
+ badge: (s) => s?.approvals_pending ?? 0,
+ source: 'builtin'
},
{
id: 'signals',
title: 'Signals',
icon: SirenIcon,
- component: Signals,
+ component: () => import('../pages/Signals.svelte'),
width: 860,
height: 620,
minWidth: 480,
minHeight: 360,
- badge: (s) => openSignalCount(s)
+ badge: (s) => openSignalCount(s),
+ source: 'builtin'
},
{
id: 'knowledge',
title: 'Knowledge',
icon: SearchIcon,
- component: Knowledge,
+ component: () => import('../pages/Knowledge.svelte'),
width: 800,
height: 600,
minWidth: 440,
- minHeight: 340
+ minHeight: 340,
+ source: 'builtin'
},
{
id: 'learning',
title: 'Learning',
icon: TrendingUpIcon,
- component: Learning,
+ component: () => import('../pages/Learning.svelte'),
width: 800,
height: 600,
minWidth: 440,
- minHeight: 340
+ minHeight: 340,
+ source: 'builtin'
},
{
id: 'settings',
title: 'Settings',
icon: SettingsIcon,
- component: Settings,
+ component: () => import('../pages/Settings.svelte'),
width: 640,
height: 480,
minWidth: 480,
- minHeight: 360
+ minHeight: 360,
+ source: 'builtin'
+ },
+ {
+ id: 'app-store',
+ title: 'App Store',
+ icon: StoreIcon,
+ component: () => import('../pages/AppStore.svelte'),
+ width: 720,
+ height: 560,
+ minWidth: 480,
+ minHeight: 360,
+ source: 'builtin'
+ },
+ {
+ id: 'mascot',
+ title: 'Cluck',
+ icon: EggIcon,
+ component: () => import('./mascot/MascotLayer.svelte'),
+ docked: true,
+ source: 'builtin'
}
]
-export const appById = new Map(APPS.map((a) => [a.id, a]))
+// --- Installed (operator-installed from the App Store) ---------------------
+
+const INSTALLED_KEY = 'oikos-installed-apps'
+
+function loadInstalled(): string[] {
+ if (typeof localStorage === 'undefined') return []
+ try {
+ const raw = localStorage.getItem(INSTALLED_KEY)
+ if (!raw) return []
+ const ids = JSON.parse(raw) as string[]
+ // Drop ids that no longer resolve to a catalog entry (the app was
+ // removed from the catalog in a later build) so they don't linger as
+ // phantom desktop icons.
+ return ids.filter((id) => catalogById.has(id))
+ } catch {
+ return []
+ }
+}
+
+// Persisted as the list of catalog manifest ids the operator has installed.
+const installedIds = writable(loadInstalled())
+
+// Readable view for components (App Store UI) that need to re-render on
+// install/uninstall. Mutations go through installApp/uninstallApp.
+export const installedAppIds: Readable = { subscribe: installedIds.subscribe }
+
+function persist(ids: string[]): void {
+ if (typeof localStorage === 'undefined') return
+ localStorage.setItem(INSTALLED_KEY, JSON.stringify(ids))
+}
+installedIds.subscribe(persist)
+
+function catalogEntryToAppDef(entry: CatalogEntry): AppDef {
+ const m = entry.manifest
+ return {
+ id: m.id,
+ title: m.title,
+ icon: entry.icon,
+ component: entry.load,
+ docked: m.docked,
+ noIcon: m.noIcon,
+ width: m.width,
+ height: m.height,
+ minWidth: m.minWidth,
+ minHeight: m.minHeight,
+ source: 'installed'
+ }
+}
+
+// The full app set: built-ins + installed catalog apps. Reactive so an
+// install/uninstall is reflected on the desktop immediately, with no reload.
+export const apps: Readable = derived(installedIds, (ids) => {
+ const installed = ids
+ .map((id) => catalogById.get(id))
+ .filter((e): e is CatalogEntry => !!e)
+ .map(catalogEntryToAppDef)
+ return [...builtinApps, ...installed]
+})
+
+export const appById: Readable