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
29 KiB
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,appByIdindex, round-trips. - Orphan cleanup:
WindowLayer.svelte:25-30closes persisted windows whose app was removed from the registry.
What's missing from the AppDef contract:
- No stable OS-service surface. Apps reach into the OS by importing
arbitrary
$libmodules (openEntityWindowfromwindows.ts,summaryfromcontext.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. - 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.sveltetakes no props. It creates theMascotRuntimeper 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.tsand survives unmount/remount. - The sprite
Imagecache is module-scoped insprites.ts— remounts do not re-fetch the 19 PNG sheets. - The stimulus bus subscribes to global stores (
focusedSessionIdfromwindows.ts, per-session factories fromchat.ts/workspace.ts) — no dependency on how MascotLayer is mounted.
Consequence: hiding the mascot = {#if visible}<MascotLayer />{/if}.
State, sprites, and position all restore naturally. No keepAlive
machinery is needed.
1.4 Three contract weaknesses
Weakness 1: Positional content resolution
WindowLayer.svelte:70-79 resolves content by checking ID patterns in a
hardcoded order:
{#if id.startsWith(SESSION_PREFIX)}
<SessionChatWindow ... />
{:else if id === NEW_TASK_WINDOW_ID}
<NewTaskChat />
{:else if app}
<app.component />
{:else}
<EntityDetailContent ... />
{/if}
A new window category must be inserted at the right position in this chain.
Works today because prefixes are mutually exclusive by construction, but
it's a landmine: add 'lxc:' container consoles or 'log:' viewers and
you're editing shell internals.
Weakness 2: Icon store snapshots the registry at module load
icons.ts:23 builds default positions from APPS, and icons.ts:48
freezes an appIds set used to filter persisted positions in load().
Both evaluate once at import time. A late-registering app (lazy load,
Phase 2+) would have its persisted position silently dropped by the
load() filter — the merge-over-defaults logic only helps apps that were
already in APPS when the module first evaluated.
Weakness 3: Window chrome is fully shell-owned, with no extension point
Every window gets the same titlebar (WindowLayer.svelte:40-67): drag
handle, title, minimize/maximize/close. Correct default — apps should not
draw their own chrome — but there is no sanctioned way for an app to
contribute a titlebar affordance (e.g. Tasks might want an inline "New
task" button). Decision: document as a designed extension point, defer
implementation until an app actually needs it (see §2.5). Not a Phase 1
deliverable.
2. The OS + Apps model
2.1 Metaphor
┌──────────────────────────────────────────────────┐
│ Auth Gate (App.svelte) │
│ ┌──────────────────────────────────────────────┐│
│ │ Desktop Surface ││
│ │ ┌─────────────┐ ┌─────────────┐ ││
│ │ │ App Window │ │ App Window │ z-40 ││
│ │ │ (Tasks) │ │ (Signals) │ ││
│ │ └─────────────┘ └─────────────┘ ││
│ │ ┌──────────────────────┐ ││
│ │ │ Docked Apps (Cluck) │ z-45, no chrome ││
│ │ └──────────────────────┘ ││
│ │ ┌──────┐ ┌──────┐ ┌──────┐ z-0 ││
│ │ │ Icon │ │ Icon │ │ Icon │ ││
│ │ └──────┘ └──────┘ └──────┘ ││
│ └──────────────────────────────────────────────┘│
│ ┌──────────────────────────────────────────────┐│
│ │ Taskbar [Tasks] [Signals] 🎨 ⚙ v0.9 ││
│ └──────────────────────────────────────────────┘│
└──────────────────────────────────────────────────┘
Base OS = Auth Gate + Desktop Surface + Window Manager + Taskbar
+ Icon Grid + Docked Layer + OS-service surface
Apps = Tasks, KB, Ops, Signals, Knowledge, Learning, Settings, Cluck
2.2 App kinds
Two kinds, distinguished by one flag:
| Kind | Window | Titlebar | Taskbar button | Opened by |
|---|---|---|---|---|
| Windowed (default) | wmkit floating window | Yes | Yes (automatic) | openAppWindow(id) → wm.open() |
Docked (docked: true) |
None — renders on the Docked Layer | No | No | openAppWindow(id) → toggles visibility |
Docked apps are not wmkit citizens. They render in a dedicated layer
above the window layer, their visibility is a persisted boolean, and
clicking their desktop icon toggles show/hide. They never appear in the
taskbar because they never enter wmState.order.
2.3 The App contract
interface AppDef {
// Identity (required)
id: string // unique; window IDs are "app:<id>"
title: string // desktop icon label + window titlebar
icon: Component // Lucide icon (desktop icon + taskbar)
component: Component // Svelte component; receives NO props
// Kind
docked?: boolean // true = Docked Layer app, no window (default false)
// Window geometry — required for windowed apps, forbidden for docked apps
width?: number
height?: number
minWidth?: number
minHeight?: number
// Behavior (all optional)
badge?: (summary: DashboardSummary | null) => number
noIcon?: boolean // true = registered but no desktop icon
}
Validation rules (enforced by apps.test.ts, not runtime checks):
idunique, non-empty.- Windowed apps:
width/heightpresent and positive. - Docked apps:
width/heightabsent (geometry is meaningless without a window). - Every app has an icon component (even
noIconapps — 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/onDestroyalready fire on window open/close. A shell-levelonRegisteris only meaningful once apps register dynamically — deferred to Phase 3, where it becomes the permission handshake. - Apps receive no props. The component is the app. It imports OS services (§2.4) directly. This keeps the shell→app edge one-way and trivially mockable.
2.4 The OS-service surface (AppOS)
The stable set of $lib exports an App may import. Everything else in
$lib is shell-internal and may change without notice. This is a
documentation contract today (apps are compiled in); it becomes an
enforced sandbox boundary in Phase 3.
| Service | Import | Stability |
|---|---|---|
| Open an app window | openAppWindow(id) from $lib/stores/windows |
Stable |
| Open an entity window | openEntityWindow(slug) from $lib/stores/windows |
Stable |
| Open a task window | openTaskWindow(sessionId, title) from $lib/stores/windows |
Stable |
| Dashboard summary | summary, subscribeContext from $lib/stores/context |
Stable |
| Live events | subscribeEvents from $lib/stores/events |
Stable |
| Per-session chat | chatFor(sessionId) from $lib/stores/chat |
Stable |
| Per-session workspace | workspaceFor(sessionId) from $lib/stores/workspace |
Stable |
| REST API | $lib/api functions |
Stable (generated from OpenAPI) |
| UI primitives | $lib/components/ui/* |
Stable |
| Theme | getTheme, setTheme from $lib/stores/theme.svelte |
Stable |
2.5 Content resolution — fixed
Replace the positional if/else chain with a prefix → component map owned
by the shell:
// WindowLayer.svelte — one map, dispatch by prefix. New window kinds
// register here, not in an if/else chain.
const CONTENT_RESOLVERS: Array<[prefix: string, resolve: (id: string) => Component | null]> = [
['session:', () => SessionChatWindow],
['app:', (id) => appById.get(id.slice(4))?.component ?? null],
]
function resolveContent(id: string): Component | null {
if (id === NEW_TASK_WINDOW_ID) return NewTaskChat
for (const [prefix, resolve] of CONTENT_RESOLVERS) {
if (id.startsWith(prefix)) return resolve(id)
}
return EntityDetailContent // bare entity slug fallback
}
Adding a 'lxc:' console window kind later = one array entry. The
existing orphan-close effect (WindowLayer.svelte:25-30) is kept as-is;
Phase 2 must gate it on registry-ready (§5).
2.6 Designed extension points (documented, not built)
| Extension | Mechanism when built | Trigger |
|---|---|---|
| Titlebar actions | titlebarActions?: Component on AppDef, rendered left of min/max/close |
First app that needs one |
| App-scoped state | state?: () => Record<string, unknown> on AppDef |
First app with cross-mount state that isn't module-scoped |
onRegister handshake |
Called with a scoped AppOS capability object | Phase 3 (dynamic install) |
Documenting these now prevents the Phase 1 contract from painting itself into a corner; building them now would be speculative.
3. The mascot as an App
3.1 Registration
{
id: 'mascot',
title: 'Cluck',
icon: EggIcon, // Lucide egg (chick/adult swap is a future nicety)
component: MascotLayer,
docked: true,
// no width/height — docked
// no badge — a permanent "1" is noise, not information
}
3.2 The docked-visibility store (new)
// lib/stores/docked.ts
// Visibility for docked apps — persisted, so "hidden" survives reloads.
// Keyed by app id; absent key = visible (default-on for new docked apps).
export const dockedVisibility: Readable<Record<string, boolean>>
export function toggleDocked(appId: string): void
export function isDockedVisible(appId: string): boolean
- localStorage key:
oikos-docked-apps - Default: visible (a fresh install shows the mascot; hiding is opt-out)
- Merge semantics mirror
icons.ts: unknown persisted keys are kept (an uninstalled docked app that gets reinstalled remembers its state)
3.3 Shell changes
windows.ts — openAppWindow branches on kind:
export function openAppWindow(appId: string): void {
const app = appById.get(appId)
if (!app) return
if (app.docked) { toggleDocked(appId); return } // ← the branch the first draft missed
// ... existing wm.open path unchanged
}
This is the load-bearing detail: the icon click in Desktop.svelte:93
calls openAppWindow(app.id) for every app uniformly. Branching inside
openAppWindow means Desktop.svelte, legacy hash resolution, and any
future caller need no special cases.
Desktop.svelte — replace hardcoded <MascotLayer /> with:
<DockedLayer />
components/desktop-shell/DockedLayer.svelte — new, ~30 lines:
{#each APPS.filter(a => a.docked) as app (app.id)}
{#if $dockedVisibility[app.id] ?? true}
<app.component />
{/if}
{/each}
Rendered after <WindowLayer /> inside the surface div, so docked apps
share the surface's coordinate space (the mascot's ground-line computation
depends on this — MascotLayer.svelte:9-13).
MascotLayer.svelte — zero changes. No props today, no props after.
3.4 What the mascot gains
| Feature | Before | After |
|---|---|---|
| Registry entry | None — hardcoded in shell | First-class AppDef |
| Show/hide | Impossible — always mounted | Icon click toggles; persists across reloads |
| Shell coupling | Desktop.svelte imports mascot internals |
Shell knows only AppDef |
| Precedent for overlay apps | None | Any docked: true app (clock, net monitor) uses the same path |
3.5 What the mascot does not gain (deliberately)
- No taskbar button. No window → no taskbar entry. The desktop icon is the control.
- No window chrome. It's a desktop creature, not a document.
- No settings panel in v1. Hatch/rename/pet/feed stay in the existing radial menu. A mascot settings surface (volume, behavior toggles) would be a separate windowed app later — noted as a follow-up idea, not planned.
3.6 UX risk: "where did my chicken go?"
Hidden state persists across reloads. Mitigation: the desktop icon is always present and is the obvious toggle; the icon's tooltip reads "Cluck — click to show/hide". Acceptable.
4. Current apps — conformance audit
| App | Conforms? | Notes |
|---|---|---|
Tasks (Overview.svelte) |
✅ Full | Self-contained. Opens session windows via openTaskWindow. |
Knowledge Base (KnowledgeBase.svelte) |
✅ Full | Opens entity windows via openEntityWindow. |
Operations (Ops.svelte) |
✅ Full | Badge reads summary. |
Signals (Signals.svelte) |
✅ Full | Opens entity windows. |
Knowledge (Knowledge.svelte) |
✅ Full | — |
Learning (Learning.svelte) |
✅ Full | — |
Settings (Settings.svelte) |
✅ Full | Opened from taskbar tray too — same openAppWindow path. |
| Mascot | ❌ Not an App | Hardcoded in Desktop.svelte. Refactored per §3. |
All seven windowed apps conform today. "Independently shippable" at Phase 1 means: add = one page file + one registry entry; remove = delete both. No shell edits, no inter-app imports (apps open each other's surfaces only through AppOS primitives).
5. Extensibility roadmap
Phase 1: Strengthen the contract (this plan)
AppDefextended:docked,noIcon; geometry conditional on kindlib/stores/docked.ts: docked-visibility store, persistedopenAppWindowbranches ondockedDockedLayer.svelte: generic docked-app layer in Desktop.svelte- Mascot registered as
docked: true; hardcoded<MascotLayer />removed - WindowLayer: prefix-map content resolution (deferred — re-audited as gold-plating; original gate already handles orphans)
apps.test.ts: validation rules per kind (§2.3)- AppOS contract documented (§2.4 lands in MBSE component doc)
Phase 2: Lazy loading
componentbecomes() => Promise<{ default: Component }>; all apps use dynamic imports- Desktop icons render immediately (metadata only); component chunk loads on window open
LazyApp.svelte— shared loading skeleton (spinner) used by WindowLayer + DockedLayer- Deleted
LazyMascot.svelte— the registry lazy loader breaks the cycle directly - Vite code-splits each app into its own chunk (main bundle 800KB → 482KB)
- Icon store revalidates against live registry (Phase 3 prerequisite — not needed while apps are statically registered)
- WindowLayer orphan-close gated on registry-ready (Phase 3 prerequisite)
Phase 3: Dynamic app installation (frontend scaffold, local bundles)
Scoped at execution time to local bundles only (remote-URL loading +
sandboxing deferred to Phase 4 — security-critical, needs ADR + careful
design). The mechanism built here generalizes to remote bundles by
swapping the catalog for a fetched manifest + import(/* @vite-ignore */ url).
AppManifestformat (id, title, permissions, version, geometry) —web/src/app-store/catalog.tsAppPermissionenum (declaration-only; enforcement is Phase 4)- Static catalog with one demo app (Notes) —
web/src/app-store/apps/Notes.svelte - Runtime registry:
APPS→ derived store (built-ins + installed);appById→ derived Map installApp/uninstallApp+ localStorage persistence (oikos-installed-apps)icons.tsreactive to app registration (late-registering apps get free cells; reset re-seeds from live registry)- WindowLayer orphan-close reactive to
$appById(reinstall revives, uninstall closes) - App Store page (
web/src/pages/AppStore.svelte) — list / install / uninstall - Installed apps appear on desktop immediately (no reload); uninstall removes icon + closes window
- Icon store revalidates against live registry (the Phase 3 prerequisite — now done)
/api/v1/appsendpoint + DB-backed manifest storage (Phase 4)- Remote bundle loading from URLs + CSP + capability sandboxing (Phase 4)
- Permission enforcement at AppOS boundary (Phase 4)
Phase 4: Marketplace (vision)
- Community apps (network map, backup dashboard, energy monitor)
- Versioning + auto-update
- Mascot skin packs as installable docked-app variants
6. Implementation — Phase 1, file by file
| # | File | Change |
|---|---|---|
| 1 | lib/apps.ts |
Extend AppDef (docked?, noIcon?, geometry optional). Register mascot. Import MascotLayer + EggIcon. |
| 2 | lib/stores/docked.ts |
New. dockedVisibility store, toggleDocked, isDockedVisible, localStorage persistence. |
| 3 | lib/stores/windows.ts |
openAppWindow: docked branch → toggleDocked. |
| 4 | components/desktop-shell/DockedLayer.svelte |
New. Renders visible docked apps after WindowLayer. |
| 5 | components/desktop-shell/Desktop.svelte |
Replace import MascotLayer + <MascotLayer /> with <DockedLayer />. |
| 6 | components/desktop-shell/WindowLayer.svelte |
Deferred during implementation. The positional if/else was re-audited and found to already handle orphans cleanly (`{#if win && (!appId |
| 7 | lib/apps.test.ts |
Mock MascotLayer import (same pattern as pages). Per-kind validation tests. Docked apps exempt from positive-size test. |
| 8 | lib/stores/docked.test.ts |
New. Toggle, persistence, default-visible, unknown-key merge. |
| 9 | docs/mbse/components.md |
Add Component 9: Web Control Room — App Architecture (§7). |
Out of scope for Phase 1: titlebarActions, app-scoped state,
lazy loading, manifests, permissions.
Verification:
cd web
npm run test # vitest — registry + docked store
npm run check # svelte-check + tsc
npm run lint
npm run build # vite build — confirms no import cycles from DockedLayer
Manual smoke: icon toggle hides/shows mascot → reload → stays hidden →
toggle → returns at last position (model lastPos restore). All seven
windowed apps open/focus/close identically to before. Legacy hash
#/signals still opens the Signals window.
7. MBSE documentation
Add Component 9: Web Control Room — App Architecture to
docs/mbse/components.md:
9. Web Control Room — App Architecture
9.1 Purpose — OS + Apps metaphor, why apps are independently shippable
9.2 Structural View — shell modules, registry, docked layer (mermaid)
9.3 App Contract — AppDef, validation rules, app kinds
9.4 OS-Service Surface — the AppOS table
9.5 Content Resolution — prefix map, window kinds, orphan cleanup
9.6 Behavior — window state machine, docked visibility lifecycle
9.7 Requirements — WEB-APP-* traceability
9.8 Verification — test coverage, manual smoke
Requirements
| ID | Requirement | Status |
|---|---|---|
| WEB-APP-1 | Apps register via data-driven AppDef entries; no shell edits to add/remove | ✅ live |
| WEB-APP-2 | Apps render in wmkit floating windows | ✅ live |
| WEB-APP-3 | Window IDs namespaced (app:/session:/entity) — no collisions |
✅ live |
| WEB-APP-4 | Desktop icons render from the registry | ✅ live |
| WEB-APP-5 | Taskbar buttons derive from window state, icons resolved via registry | ✅ live |
| WEB-APP-6 | Removed apps' persisted windows self-close | ✅ live (WindowLayer.svelte:25-30) |
| WEB-APP-7 | Content resolution dispatches via prefix map, not positional if/else | ⬜ Deferred — re-audited; original gate already handles orphans, map adds no decoupling (§2.5) |
| WEB-APP-8 | Docked app kind: no window, no chrome, visibility toggled via icon | ⬜ Phase 1 |
| WEB-APP-9 | Mascot is a registered docked App, not a hardcoded shell component | ⬜ Phase 1 |
| WEB-APP-10 | Docked visibility persists across reloads | ⬜ Phase 1 |
| WEB-APP-11 | OS-service surface (AppOS) documented as the stable App API | ⬜ Phase 1 |
| WEB-APP-12 | Registry validation: per-kind geometry rules enforced by tests | ⬜ Phase 1 |
| WEB-APP-13 | Apps lazy-load; icons render from static metadata | ✅ Phase 2 |
| WEB-APP-14 | Icon store revalidates against live registry, not import-time snapshot | ✅ Phase 3 |
| WEB-APP-15 | Third-party apps install from manifests with declared permissions | ✅ Phase 3 (local bundles; enforcement Phase 4) |
Sequence — windowed app open
sequenceDiagram
participant User
participant Desktop
participant WM as Window Manager
participant WL as Window Layer
participant App
User->>Desktop: click icon
Desktop->>WM: openAppWindow("signals")
Note over WM: docked? no → wm path
alt window exists
WM->>WM: restore + focus
else new
WM->>WM: wm.open({ id: "app:signals", ... })
WM->>WL: render frame
WL->>WL: resolveContent → prefix 'app:' → registry
WL->>App: mount component
end
WM->>Taskbar: new button in wmState.order
Sequence — docked app toggle
sequenceDiagram
participant User
participant Desktop
participant Dock as docked.ts
participant Layer as DockedLayer
participant App
User->>Desktop: click Cluck icon
Desktop->>Dock: openAppWindow("mascot") → docked → toggleDocked
Dock->>Dock: flip visibility, persist localStorage
Dock->>Layer: store update
alt now visible
Layer->>App: mount MascotLayer
Note over App: model + sprites restore<br/>from module scope
else now hidden
Layer->>App: unmount (state survives)
end
State machine — app window
stateDiagram-v2
[*] --> Closed: registered, no window
Closed --> Open: openAppWindow
Open --> Focused: focus
Focused --> Open: blur
Open --> Minimized: minimize
Minimized --> Focused: restore
Open --> Closed: close
Minimized --> Closed: close
8. Risk & safety
| Risk | Severity | Mitigation |
|---|---|---|
| Mascot refactor breaks stimuli or ground-line computation | Medium | MascotLayer unchanged; DockedLayer mounts it in the same surface div, same position in the stacking order as today. |
| Hidden mascot never rediscovered | Low | Icon always present, tooltip says show/hide. |
openAppWindow docked branch leaks into windowed path |
Low | Branch is the first statement; windowed path byte-identical. Covered by existing call sites (icon click, taskbar settings, legacy hash). |
| Docked visibility store desyncs from registry | Low | Unknown keys kept on load; layer filters by a.docked from the live registry. |
| Phase 2 lazy loading kills persisted windows of not-yet-loaded apps | Medium | Explicit Phase 2 gate: orphan-close waits for registry-ready (§5). Called out now so it isn't discovered in production. |
9. Appendix: relevant existing artifacts
| Artifact | Relevance |
|---|---|
docs/mbse/README.md §5 |
MCP tools / REST / SSE — the data surface Apps consume |
docs/mbse/components.md §5 |
Current web control room component doc — Phase 1 extends it |
docs/mascot/README.md |
Mascot subsystem model (MASC-1..12); MASC-9's registry philosophy is the template for this plan |
plans/2026-07-08-control-room-webui.md |
Original control-room plan |
plans/done/2026-07-11-ui-review-ia-usability.md |
IA review that produced the desktop metaphor |
plans/2026-07-20-desktop-mascot.md |
Mascot plan; extension registries |
lib/apps.ts header comment |
Already documents the one-entry-to-add-an-app philosophy |
Plan opened 2026-07-21. Phase 1 ready for execution — estimated small (~half a day of focused work; nine file touches, two new files). Phases 2–4 are context for future sessions and do not block Phase 1.