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