feat(web): app-registry architecture — OS + Apps, lazy loading, installable apps
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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
This commit is contained in:
2026-07-21 14:37:36 +02:00
parent 50aed11cc4
commit 482c7f3448
30 changed files with 1597 additions and 127 deletions

View File

@@ -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, `<WindowLayer />`, `<DockedLayer />`, 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 `<MascotLayer />`. |
| `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:<id>"
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:<id>` | the registry app's component (`appById.get(id).component`) |
| `session:<id>` | `SessionChatWindow` (per-session chat) |
| `new-task` | `NewTaskChat` (singleton compose) |
| bare slug (`type:identifier`) | `EntityDetailContent` (fallback) |
A hydrated `app:<id>` 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 `<MascotLayer />` 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<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) |
| 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