From 482c7f3448d56b7fcc1df0e8c1d83f34950bd100 Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 21 Jul 2026 14:37:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20app-registry=20architecture=20?= =?UTF-8?q?=E2=80=94=20OS=20+=20Apps,=20lazy=20loading,=20installable=20ap?= =?UTF-8?q?ps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ; 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 --- VERSION | 2 +- docs/mbse/components.md | 178 ++++- ...026-07-21-frontend-os-apps-architecture.md | 647 ++++++++++++++++++ plans/index.md | 1 + web/src/lib/app-store/apps/Notes.svelte | 57 ++ web/src/lib/app-store/catalog.ts | 81 +++ web/src/lib/apps.test.ts | 128 +++- web/src/lib/apps.ts | 215 +++++- web/src/lib/components/EntityTable.svelte | 2 +- .../components/data-table/DataTable.svelte | 6 +- ...search-input.svelte => SearchInput.svelte} | 0 .../{sort-header.svelte => SortHeader.svelte} | 0 .../{toolbar.svelte => Toolbar.svelte} | 2 +- web/src/lib/components/data-table/columns.ts | 2 +- .../{DataTable.svelte.ts => types.ts} | 0 .../components/desktop-shell/Desktop.svelte | 8 +- .../desktop-shell/DockedLayer.svelte | 24 + .../components/desktop-shell/LazyApp.svelte | 35 + .../components/desktop-shell/Taskbar.svelte | 4 +- .../desktop-shell/WindowLayer.svelte | 16 +- web/src/lib/mascot/Mascot.svelte | 11 +- web/src/lib/stores/docked.test.ts | 62 ++ web/src/lib/stores/docked.ts | 43 ++ web/src/lib/stores/icons.test.ts | 20 +- web/src/lib/stores/icons.ts | 68 +- web/src/lib/stores/windows.ts | 14 +- web/src/pages/AppStore.svelte | 92 +++ web/src/pages/Ops.svelte | 2 +- web/src/pages/Overview.svelte | 2 +- web/src/pages/Signals.svelte | 2 +- 30 files changed, 1597 insertions(+), 127 deletions(-) create mode 100644 plans/2026-07-21-frontend-os-apps-architecture.md create mode 100644 web/src/lib/app-store/apps/Notes.svelte create mode 100644 web/src/lib/app-store/catalog.ts rename web/src/lib/components/data-table/{search-input.svelte => SearchInput.svelte} (100%) rename web/src/lib/components/data-table/{sort-header.svelte => SortHeader.svelte} (100%) rename web/src/lib/components/data-table/{toolbar.svelte => Toolbar.svelte} (94%) rename web/src/lib/components/data-table/{DataTable.svelte.ts => types.ts} (100%) create mode 100644 web/src/lib/components/desktop-shell/DockedLayer.svelte create mode 100644 web/src/lib/components/desktop-shell/LazyApp.svelte create mode 100644 web/src/lib/stores/docked.test.ts create mode 100644 web/src/lib/stores/docked.ts create mode 100644 web/src/pages/AppStore.svelte 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> = derived(apps, (list) => + new Map(list.map((a) => [a.id, a])) +) + +// Install/uninstall. Idempotent — installing an already-installed app or +// uninstalling a not-installed one is a no-op. Uninstalling a built-in is +// refused (built-ins can't be removed). +export function installApp(manifestId: string): void { + if (!catalogById.has(manifestId)) return + installedIds.update((ids) => (ids.includes(manifestId) ? ids : [...ids, manifestId])) +} + +export function uninstallApp(manifestId: string): void { + installedIds.update((ids) => ids.filter((id) => id !== manifestId)) +} + +export function isInstalled(manifestId: string): boolean { + return get(installedIds).includes(manifestId) +} + +// --- Window-id helpers (unchanged from the static-registry era) ----------- // Window ids are namespaced so WindowLayer.svelte can tell at a glance which // content branch owns an id: `app:` for registry apps, `session:` diff --git a/web/src/lib/components/EntityTable.svelte b/web/src/lib/components/EntityTable.svelte index 06bc1d0..5781755 100644 --- a/web/src/lib/components/EntityTable.svelte +++ b/web/src/lib/components/EntityTable.svelte @@ -3,7 +3,7 @@ import * as Table from '$lib/components/ui/table' import { Badge } from '$lib/components/ui/badge' import { Skeleton } from '$lib/components/ui/skeleton' - import SortHeader from '$lib/components/data-table/sort-header.svelte' + import SortHeader from '$lib/components/data-table/SortHeader.svelte' import EmptyState from '$lib/components/EmptyState.svelte' import HealthDotRenderer from '$lib/components/data-table/renderers/HealthDotRenderer.svelte' import StatusBadgeRenderer from '$lib/components/data-table/renderers/StatusBadgeRenderer.svelte' diff --git a/web/src/lib/components/data-table/DataTable.svelte b/web/src/lib/components/data-table/DataTable.svelte index bfb9be4..5ec6fe2 100644 --- a/web/src/lib/components/data-table/DataTable.svelte +++ b/web/src/lib/components/data-table/DataTable.svelte @@ -1,8 +1,8 @@ + +
+ {#each dockedApps as app (app.id)} + {#if $dockedVisibility[app.id] ?? true} + + {/if} + {/each} +
diff --git a/web/src/lib/components/desktop-shell/LazyApp.svelte b/web/src/lib/components/desktop-shell/LazyApp.svelte new file mode 100644 index 0000000..3b61def --- /dev/null +++ b/web/src/lib/components/desktop-shell/LazyApp.svelte @@ -0,0 +1,35 @@ + + +{#await promise} +
+ +
+{:then mod} + {@const C = mod.default} + +{:catch error} +
+ Failed to load app: {(error as Error).message} +
+{/await} diff --git a/web/src/lib/components/desktop-shell/Taskbar.svelte b/web/src/lib/components/desktop-shell/Taskbar.svelte index f51125d..fab42d9 100644 --- a/web/src/lib/components/desktop-shell/Taskbar.svelte +++ b/web/src/lib/components/desktop-shell/Taskbar.svelte @@ -30,14 +30,14 @@ function iconFor(id: string) { const appId = appIdFromWindowId(id) - if (appId) return appById.get(appId)?.icon + if (appId) return $appById.get(appId)?.icon if (id.startsWith(SESSION_PREFIX)) return MessageSquareIcon return DatabaseIcon } function badgeFor(id: string): number { const appId = appIdFromWindowId(id) - const app = appId ? appById.get(appId) : undefined + const app = appId ? $appById.get(appId) : undefined return app?.badge?.($summary) ?? 0 } diff --git a/web/src/lib/components/desktop-shell/WindowLayer.svelte b/web/src/lib/components/desktop-shell/WindowLayer.svelte index 3976608..c56484f 100644 --- a/web/src/lib/components/desktop-shell/WindowLayer.svelte +++ b/web/src/lib/components/desktop-shell/WindowLayer.svelte @@ -14,18 +14,22 @@ import EntityDetailContent from '../EntityDetailContent.svelte' import SessionChatWindow from '../SessionChatWindow.svelte' import NewTaskChat from './NewTaskChat.svelte' + import LazyApp from '$lib/components/desktop-shell/LazyApp.svelte' import XIcon from '@lucide/svelte/icons/x' import MinusIcon from '@lucide/svelte/icons/minus' import Maximize2Icon from '@lucide/svelte/icons/maximize-2' // A hydrated `app:` window whose id no longer matches any registry - // entry (the app was renamed/removed since the layout was persisted) has - // nothing to render — close it rather than leaving a permanently-blank - // window stuck in the taskbar. + // entry (the app was uninstalled/removed since the layout was persisted) + // has nothing to render — close it rather than leaving a permanently-blank + // window stuck in the taskbar. Reactive on `appById` so reinstalling an + // app revives its persisted window on the next tick rather than requiring + // a reload, and uninstalling closes its orphan window immediately. $effect(() => { + const idx = $appById for (const id of $wmState.order) { const appId = appIdFromWindowId(id) - if (appId && !appById.has(appId)) wm.close(id) + if (appId && !idx.has(appId)) wm.close(id) } }) @@ -34,7 +38,7 @@ {#each $wmState.order as id (id)} {@const win = $wmState.windows[id]} {@const appId = appIdFromWindowId(id)} - {@const app = appId ? appById.get(appId) : undefined} + {@const app = appId ? $appById.get(appId) : undefined} {#if win && (!appId || app)}
@@ -73,7 +77,7 @@ {:else if id === NEW_TASK_WINDOW_ID} {:else if app} - + {:else} {/if} diff --git a/web/src/lib/mascot/Mascot.svelte b/web/src/lib/mascot/Mascot.svelte index 7225d26..1838b2d 100644 --- a/web/src/lib/mascot/Mascot.svelte +++ b/web/src/lib/mascot/Mascot.svelte @@ -32,7 +32,6 @@ } from '$lib/mascot/state.svelte' import { wmState } from '$lib/stores/windows' import { getIconPositions, iconPixelPos, GRID } from '$lib/stores/icons' - import { APPS, type AppDef } from '$lib/apps' // MascotRuntime is created fresh per mount; the long-lived MascotModel // (with lastPos, stage, name, ...) persists across mounts via localStorage. @@ -217,13 +216,13 @@ } const ICON_INVESTIGATE_FALLBACK = '👀 Ooh!' - /** Which desktop-icon app (if any) the given surface coords land on, using the same grid DesktopIcon.svelte renders with. */ - function iconAt(x: number, y: number): AppDef | null { + /** Which desktop-icon app (if any) the given surface coords land on, using the same grid DesktopIcon.svelte renders with. Returns the app id (the key the investigate-bubble map is indexed by) — iterating the icon-positions store rather than the APPS registry avoids a static import cycle (apps.ts -> MascotLayer -> here -> apps.ts), see docked visibility store wiring. */ + function iconAt(x: number, y: number): string | null { const positions = getIconPositions() - for (const app of APPS) { - const pos = positions[app.id] ?? { col: 0, row: 0 } + for (const id of Object.keys(positions)) { + const pos = positions[id] ?? { col: 0, row: 0 } const { x: ix, y: iy } = iconPixelPos(pos) - if (x >= ix && x <= ix + GRID.cell && y >= iy && y <= iy + GRID.cell) return app + if (x >= ix && x <= ix + GRID.cell && y >= iy && y <= iy + GRID.cell) return id } return null } diff --git a/web/src/lib/stores/docked.test.ts b/web/src/lib/stores/docked.test.ts new file mode 100644 index 0000000..c74c3d1 --- /dev/null +++ b/web/src/lib/stores/docked.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// docked.ts no longer imports $lib/apps (defaults are implicit: absent key +// = visible), so no mock is needed. Each test re-imports the module fresh +// (after clearing localStorage) so the module-scoped store starts from the +// cleared state every time — without this, the store's state leaks between +// tests since it's cached with the module instance. +import type * as Docked from './docked' + +let mod: typeof Docked + +beforeEach(async () => { + localStorage.clear() + vi.resetModules() + mod = await import('./docked') +}) + +describe('docked visibility store', () => { + it('defaults docked apps to visible', () => { + expect(mod.isDockedVisible('mascot')).toBe(true) + expect(mod.isDockedVisible('other-docked')).toBe(true) + }) + + it('treats unknown ids as visible (absent key = visible)', () => { + expect(mod.isDockedVisible('nope')).toBe(true) + }) + + it('toggle flips visibility', () => { + mod.toggleDocked('mascot') + expect(mod.isDockedVisible('mascot')).toBe(false) + mod.toggleDocked('mascot') + expect(mod.isDockedVisible('mascot')).toBe(true) + }) + + it('persists to localStorage', () => { + mod.toggleDocked('mascot') + const raw = localStorage.getItem(mod.STORAGE_KEY) + expect(raw).toBeTruthy() + expect(JSON.parse(raw!).mascot).toBe(false) + }) + + it('merge over defaults so a newly-registered docked app is visible', async () => { + // Simulate a persisted blob from before 'other-docked' existed. + localStorage.setItem(mod.STORAGE_KEY, JSON.stringify({ mascot: false })) + vi.resetModules() + const fresh = await import('./docked') + expect(fresh.isDockedVisible('mascot')).toBe(false) + expect(fresh.isDockedVisible('other-docked')).toBe(true) + }) + + it('dockedVisibility store is subscribable', () => { + let latest: Record | undefined + const unsub = mod.dockedVisibility.subscribe((v) => (latest = v)) + // Store starts empty — absent keys mean visible (isDockedVisible fallback). + expect(latest).toEqual({}) + expect(mod.isDockedVisible('mascot')).toBe(true) + mod.toggleDocked('mascot') + expect(latest?.mascot).toBe(false) + expect(mod.isDockedVisible('mascot')).toBe(false) + unsub() + }) +}) diff --git a/web/src/lib/stores/docked.ts b/web/src/lib/stores/docked.ts new file mode 100644 index 0000000..7492715 --- /dev/null +++ b/web/src/lib/stores/docked.ts @@ -0,0 +1,43 @@ +// Visibility for docked apps — persisted so "hidden" survives reloads. +// Keyed by app id; an ABSENT key means visible (the default for a newly +// registered docked app, so a fresh install shows the mascot without the +// operator opting in). This means the store only holds *overrides* — it +// doesn't need to enumerate the docked apps to seed defaults, which would +// require importing APPS and create a static cycle (apps.ts -> pages -> +// windows.ts -> here -> apps.ts, TDZ on APPS at init). Unknown persisted +// keys are kept (merge semantics), so an uninstalled-then-reinstalled +// docked app remembers its visibility across the gap. +import { writable, get } from 'svelte/store' + +export const STORAGE_KEY = 'oikos-docked-apps' + +function load(): Record { + if (typeof localStorage === 'undefined') return {} + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return {} + return JSON.parse(raw) as Record + } catch { + return {} + } +} + +const _visibility = writable>(load()) + +function persist(vis: Record): void { + if (typeof localStorage === 'undefined') return + localStorage.setItem(STORAGE_KEY, JSON.stringify(vis)) +} + +_visibility.subscribe(persist) + +// Read-only surface for components; mutations go through toggleDocked. +export const dockedVisibility = { subscribe: _visibility.subscribe } + +export function toggleDocked(appId: string): void { + _visibility.update((vis) => ({ ...vis, [appId]: !(vis[appId] ?? true) })) +} + +export function isDockedVisible(appId: string): boolean { + return get(_visibility)[appId] ?? true +} diff --git a/web/src/lib/stores/icons.test.ts b/web/src/lib/stores/icons.test.ts index d383ba1..f6a9720 100644 --- a/web/src/lib/stores/icons.test.ts +++ b/web/src/lib/stores/icons.test.ts @@ -1,11 +1,19 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -// icons.ts only needs APPS for its default-layout ids — stub it out rather -// than pull in the real registry's full page-component graph (see -// apps.test.ts for why that graph is expensive/broken under vitest). -vi.mock('$lib/apps', () => ({ - APPS: [{ id: 'tasks' }, { id: 'kb' }, { id: 'ops' }, { id: 'signals' }, { id: 'knowledge' }, { id: 'learning' }] -})) +// icons.ts reads the reactive `apps` store (derived from built-ins + +// installed apps) to seed default positions. Stub it as a minimal Readable +// emitting a fixed list rather than pull in the real registry. +vi.mock('$lib/apps', () => { + const list = [ + { id: 'tasks' }, + { id: 'kb' }, + { id: 'ops' }, + { id: 'signals' }, + { id: 'knowledge' }, + { id: 'learning' } + ] + return { apps: { subscribe: (cb: (v: typeof list) => void) => { cb(list); return () => {} } } } +}) import { GRID, iconPositions, placeIcon, iconPixelPos, maxCols, getIconPositions, resetIconLayout } from './icons' diff --git a/web/src/lib/stores/icons.ts b/web/src/lib/stores/icons.ts index 417e6a0..6bc4114 100644 --- a/web/src/lib/stores/icons.ts +++ b/web/src/lib/stores/icons.ts @@ -5,8 +5,14 @@ // simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing // that inside wmkit would mean fighting its window-shaped abstractions for // no benefit. +// +// Reactive to the apps store (Phase 3): when an installed app registers +// after init, it gets a free cell on the next emission. Uninstalled apps' +// positions are KEPT (so reinstall remembers where the icon was) but their +// icons simply don't render — the desktop's {#each $apps} is the source of +// truth for what's visible. import { writable, get } from 'svelte/store' -import { APPS } from '$lib/apps' +import { apps } from '$lib/apps' export interface IconPos { col: number @@ -17,36 +23,17 @@ export const GRID = { cell: 96, gap: 12, padding: 16 } const STORAGE_KEY = 'oikos-desktop-icons' -function defaultPositions(): Record { - // Classic OS default: one left-edge column, registry order. - const out: Record = {} - APPS.forEach((app, i) => { - out[app.id] = { col: 0, row: i } - }) - return out -} - function load(): Record { - if (typeof localStorage === 'undefined') return defaultPositions() + if (typeof localStorage === 'undefined') return {} try { const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return defaultPositions() - const parsed = JSON.parse(raw) as Record - const out = defaultPositions() - // Merge over the defaults so a newly-registered app (not in the saved - // blob yet) still gets a sane starting position instead of being absent - // from the grid entirely. - for (const [id, pos] of Object.entries(parsed)) { - if (appIds.has(id)) out[id] = pos - } - return out + if (!raw) return {} + return JSON.parse(raw) as Record } catch { - return defaultPositions() + return {} } } -const appIds = new Set(APPS.map((a) => a.id)) - export const iconPositions = writable>(load()) function persist(positions: Record): void { @@ -54,7 +41,7 @@ function persist(positions: Record): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(positions)) } -iconPositions.subscribe((positions) => persist(positions)) +iconPositions.subscribe(persist) function occupied(positions: Record, col: number, row: number, exceptId: string): boolean { return Object.entries(positions).some(([id, p]) => id !== exceptId && p.col === col && p.row === row) @@ -108,6 +95,35 @@ export function getIconPositions(): Record { // Bails a messy manual layout back to the classic left-edge column, // registry order — the desktop's right-click menu's "Reset icon layout". +// Clears all positions then re-seeds from the current app list, so it +// respects the live registry (including installed apps) rather than a +// static snapshot. export function resetIconLayout(): void { - iconPositions.set(defaultPositions()) + iconPositions.set(seedMissing({}, get(apps))) } + +// Seeds positions for any app in `list` that doesn't have one yet — +// classic OS default: one left-edge column, registry order. Returns the +// same positions object if nothing needed seeding (so callers can skip a +// no-op set), otherwise a fresh merged object. +function seedMissing(positions: Record, list: { id: string }[]): Record { + let next: Record | null = null + let row = 0 + for (const app of list) { + if (positions[app.id]) continue + if (!next) next = { ...positions } + while (occupied(next, 0, row, app.id)) row++ + next![app.id] = { col: 0, row } + row++ + } + return next ?? positions +} + +// Seed on every apps-store emission so a newly-installed app gets a cell +// immediately. Existing positions (including uninstalled apps' remembered +// spots) are preserved. +apps.subscribe((list) => { + const current = get(iconPositions) + const next = seedMissing(current, list) + if (next !== current) iconPositions.set(next) +}) diff --git a/web/src/lib/stores/windows.ts b/web/src/lib/stores/windows.ts index 967735e..737872d 100644 --- a/web/src/lib/stores/windows.ts +++ b/web/src/lib/stores/windows.ts @@ -3,10 +3,11 @@ // opened from Knowledge Base, chat, or anywhere else land in the same // floating window layer, with several windows open side by side, rather than // each page owning its own single-entity sidebar/sheet. -import { derived, type Readable } from 'svelte/store' +import { derived, get, type Readable } from 'svelte/store' import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte' import { persist } from '@surdeddd/wmkit/persist' import { appById, appWindowId } from '$lib/apps' +import { toggleDocked } from '$lib/stores/docked' import { sessions } from '$lib/stores/chat' import { heading } from '$lib/tasks' @@ -75,9 +76,18 @@ export function toggleShowDesktop(): void { // Opens (or focuses/restores) a registry app's window. Apps are // single-instance — double-clicking an already-open app's icon should never // stack a second window, same dedupe pattern as openEntityWindow below. +// Docked apps (e.g. the mascot) have no wmkit window at all — clicking their +// icon toggles visibility on the Docked Layer instead, so this branches on +// kind before touching the window manager. All callers (the desktop icon, +// the taskbar settings button, legacy hash resolution) go through here, so +// none of them need a kind-specific branch. export function openAppWindow(appId: string): void { - const app = appById.get(appId) + const app = get(appById).get(appId) if (!app) return + if (app.docked) { + toggleDocked(appId) + return + } const id = appWindowId(appId) if (wm.get(id)) { wm.restore(id) diff --git a/web/src/pages/AppStore.svelte b/web/src/pages/AppStore.svelte new file mode 100644 index 0000000..70c44b5 --- /dev/null +++ b/web/src/pages/AppStore.svelte @@ -0,0 +1,92 @@ + + +
+
+

App Store

+

+ Installable apps. Local bundles for now — a remote catalog + sandboxing + is Phase 4. +

+
+ +
+ {#if CATALOG.length === 0} +

No apps available yet.

+ {:else} +
    + {#each CATALOG as entry (entry.manifest.id)} + {@const m = entry.manifest} + {@const isOn = installedSet.has(m.id)} +
  • +
    + +
    +
    +
    +

    {m.title}

    + v{m.version} + {#if m.author}by {m.author}{/if} +
    +

    {m.description}

    +
    + + + {#if m.permissions.length}{m.permissions.join(', ')}{:else}no permissions requested{/if} + +
    +
    +
    + {#if isOn} + + {:else} + + {/if} +
    +
  • + {/each} +
+ {/if} + +
+

+ + Installed apps appear on the desktop immediately. Uninstalling closes + any open window for that app. +

+
+
+
diff --git a/web/src/pages/Ops.svelte b/web/src/pages/Ops.svelte index 1b323a6..ccb7ae0 100644 --- a/web/src/pages/Ops.svelte +++ b/web/src/pages/Ops.svelte @@ -13,7 +13,7 @@ import { Badge } from '$lib/components/ui/badge' import { toast } from 'svelte-sonner' import DataTable from '$lib/components/data-table/DataTable.svelte' - import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts' + import type { DataTableColumn } from '$lib/components/data-table/types' import ApprovalActions from '$lib/components/data-table/renderers/ApprovalActions.svelte' import ActivityCancel from '$lib/components/data-table/renderers/ActivityCancel.svelte' import ActivityAction from '$lib/components/data-table/renderers/ActivityAction.svelte' diff --git a/web/src/pages/Overview.svelte b/web/src/pages/Overview.svelte index 155ae92..e9a3155 100644 --- a/web/src/pages/Overview.svelte +++ b/web/src/pages/Overview.svelte @@ -6,7 +6,7 @@ import { bucket, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks' import { Button } from '$lib/components/ui/button' import DataTable from '$lib/components/data-table/DataTable.svelte' - import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts' + import type { DataTableColumn } from '$lib/components/data-table/types' import StatusDotRenderer from '$lib/components/data-table/renderers/StatusDotRenderer.svelte' import PlusIcon from '@lucide/svelte/icons/plus' import type { Session } from '$lib/api' diff --git a/web/src/pages/Signals.svelte b/web/src/pages/Signals.svelte index 02f6a19..a045af6 100644 --- a/web/src/pages/Signals.svelte +++ b/web/src/pages/Signals.svelte @@ -7,7 +7,7 @@ import * as Select from '$lib/components/ui/select' import { toast } from 'svelte-sonner' import DataTable from '$lib/components/data-table/DataTable.svelte' - import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts' + import type { DataTableColumn } from '$lib/components/data-table/types' import SignalActions from '$lib/components/data-table/renderers/SignalActions.svelte' let signals = $state([])