feat(web): app-registry architecture — OS + Apps, lazy loading, installable apps
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:
@@ -31,6 +31,7 @@ the relevant section here.
|
|||||||
| [6. PostgreSQL/TimescaleDB](#6-postgresqltimescaledb) | `migrations/`, `seeds/` | ✅ live — the System's own source of truth |
|
| [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 |
|
| [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 |
|
| [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`
|
Standalone deploy, versioned and released independently of the `oikos`
|
||||||
binary — see [README.md §4.5](README.md#45-build--release-artifacts) for
|
binary — see [README.md §4.5](README.md#45-build--release-artifacts) for
|
||||||
why "deployed" means two different release cadences depending on whether
|
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
|
## Keeping this document current
|
||||||
|
|
||||||
The same discipline as README.md's closing note applies here, scoped to
|
The same discipline as README.md's closing note applies here, scoped to
|
||||||
|
|||||||
647
plans/2026-07-21-frontend-os-apps-architecture.md
Normal file
647
plans/2026-07-21-frontend-os-apps-architecture.md
Normal file
@@ -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/<slug>` bookmarks. |
|
||||||
|
|
||||||
|
The shell has **no hardcoded app list** — `Desktop.svelte:90` reads `APPS`
|
||||||
|
from the registry, `WindowLayer.svelte:36-37` resolves app windows through
|
||||||
|
`appById`, `Taskbar.svelte:32` resolves icons the same way. Adding an app
|
||||||
|
is one entry in `apps.ts`.
|
||||||
|
|
||||||
|
### 1.2 The App Registry (exists, nearly complete)
|
||||||
|
|
||||||
|
**File:** `lib/apps.ts` (130 lines)
|
||||||
|
**Interface:** `AppDef` — id, title, icon (Lucide Component), component
|
||||||
|
(Svelte Component), width, height, minWidth, minHeight, optional badge
|
||||||
|
function.
|
||||||
|
**Window namespacing:** `app:<id>` (`apps.ts:122`) — distinct from
|
||||||
|
`session:<id>`, `new-task`, and bare entity slugs.
|
||||||
|
|
||||||
|
**Current apps (7):**
|
||||||
|
|
||||||
|
| ID | Page Component | Badge? |
|
||||||
|
|----|---------------|--------|
|
||||||
|
| `tasks` | `pages/Overview.svelte` | — |
|
||||||
|
| `kb` | `pages/KnowledgeBase.svelte` | — |
|
||||||
|
| `ops` | `pages/Ops.svelte` | approvals_pending |
|
||||||
|
| `signals` | `pages/Signals.svelte` | open signal count |
|
||||||
|
| `knowledge` | `pages/Knowledge.svelte` | — |
|
||||||
|
| `learning` | `pages/Learning.svelte` | — |
|
||||||
|
| `settings` | `pages/Settings.svelte` | — |
|
||||||
|
|
||||||
|
**What works:**
|
||||||
|
|
||||||
|
- Data-driven. One array → three surfaces auto-render.
|
||||||
|
- Namespaced window IDs prevent collisions with session/entity windows.
|
||||||
|
- Single-instance enforcement (double-click focuses, never duplicates).
|
||||||
|
- Badge system: pure function over `DashboardSummary`, consumed by icon +
|
||||||
|
taskbar.
|
||||||
|
- Tested (`apps.test.ts`): unique IDs, positive sizes, `appById` index,
|
||||||
|
round-trips.
|
||||||
|
- Orphan cleanup: `WindowLayer.svelte:25-30` closes persisted windows whose
|
||||||
|
app was removed from the registry.
|
||||||
|
|
||||||
|
**What's missing from the AppDef contract:**
|
||||||
|
|
||||||
|
1. **No stable OS-service surface.** Apps reach into the OS by importing
|
||||||
|
arbitrary `$lib` modules (`openEntityWindow` from `windows.ts`,
|
||||||
|
`summary` from `context.ts`). It works because apps are compiled in, but
|
||||||
|
there is no documented boundary between "stable OS API an App may use"
|
||||||
|
and "shell internals that happen to be exported." Phase 3 (installed
|
||||||
|
third-party apps) needs that boundary to exist first.
|
||||||
|
2. **No docked/overlay app kind.** An app that renders *on* the desktop
|
||||||
|
(above windows, no titlebar, no window at all) has no representation in
|
||||||
|
the contract — which is exactly why the mascot is hardcoded.
|
||||||
|
|
||||||
|
### 1.3 The Mascot: embedded, not an app
|
||||||
|
|
||||||
|
**Files:** `lib/mascot/` (12 files, ~2.8k lines)
|
||||||
|
**Integration:** `Desktop.svelte:105` — hardcoded `<MascotLayer />` at z-45,
|
||||||
|
after WindowLayer and before Taskbar.
|
||||||
|
|
||||||
|
**Key facts that shape the refactor (verified):**
|
||||||
|
|
||||||
|
- `MascotLayer.svelte` takes **no props**. It creates the `MascotRuntime`
|
||||||
|
per mount, seeds position from the persisted model, and attaches the
|
||||||
|
stimulus bus itself (`MascotLayer.svelte:38-61`, comment at line 6-7).
|
||||||
|
- The persistent model (stage, name, happiness, xp, **lastPos**) is
|
||||||
|
module-scoped in `state.svelte.ts` and survives unmount/remount.
|
||||||
|
- The sprite `Image` cache is module-scoped in `sprites.ts` — remounts do
|
||||||
|
not re-fetch the 19 PNG sheets.
|
||||||
|
- The stimulus bus subscribes to global stores (`focusedSessionId` from
|
||||||
|
`windows.ts`, per-session factories from `chat.ts`/`workspace.ts`) — no
|
||||||
|
dependency on how MascotLayer is mounted.
|
||||||
|
|
||||||
|
**Consequence:** hiding the mascot = `{#if visible}<MascotLayer />{/if}`.
|
||||||
|
State, sprites, and position all restore naturally. No `keepAlive`
|
||||||
|
machinery is needed.
|
||||||
|
|
||||||
|
### 1.4 Three contract weaknesses
|
||||||
|
|
||||||
|
#### Weakness 1: Positional content resolution
|
||||||
|
|
||||||
|
`WindowLayer.svelte:70-79` resolves content by checking ID patterns in a
|
||||||
|
hardcoded order:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#if id.startsWith(SESSION_PREFIX)}
|
||||||
|
<SessionChatWindow ... />
|
||||||
|
{:else if id === NEW_TASK_WINDOW_ID}
|
||||||
|
<NewTaskChat />
|
||||||
|
{:else if app}
|
||||||
|
<app.component />
|
||||||
|
{:else}
|
||||||
|
<EntityDetailContent ... />
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
A new window category must be inserted at the right position in this chain.
|
||||||
|
Works today because prefixes are mutually exclusive by construction, but
|
||||||
|
it's a landmine: add `'lxc:'` container consoles or `'log:'` viewers and
|
||||||
|
you're editing shell internals.
|
||||||
|
|
||||||
|
#### Weakness 2: Icon store snapshots the registry at module load
|
||||||
|
|
||||||
|
`icons.ts:23` builds default positions from `APPS`, and `icons.ts:48`
|
||||||
|
freezes an `appIds` set used to filter persisted positions in `load()`.
|
||||||
|
Both evaluate **once at import time**. A late-registering app (lazy load,
|
||||||
|
Phase 2+) would have its persisted position silently dropped by the
|
||||||
|
`load()` filter — the merge-over-defaults logic only helps apps that were
|
||||||
|
already in `APPS` when the module first evaluated.
|
||||||
|
|
||||||
|
#### Weakness 3: Window chrome is fully shell-owned, with no extension point
|
||||||
|
|
||||||
|
Every window gets the same titlebar (`WindowLayer.svelte:40-67`): drag
|
||||||
|
handle, title, minimize/maximize/close. Correct default — apps should not
|
||||||
|
draw their own chrome — but there is no sanctioned way for an app to
|
||||||
|
contribute a titlebar affordance (e.g. Tasks might want an inline "New
|
||||||
|
task" button). **Decision: document as a designed extension point, defer
|
||||||
|
implementation until an app actually needs it** (see §2.5). Not a Phase 1
|
||||||
|
deliverable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The OS + Apps model
|
||||||
|
|
||||||
|
### 2.1 Metaphor
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ Auth Gate (App.svelte) │
|
||||||
|
│ ┌──────────────────────────────────────────────┐│
|
||||||
|
│ │ Desktop Surface ││
|
||||||
|
│ │ ┌─────────────┐ ┌─────────────┐ ││
|
||||||
|
│ │ │ App Window │ │ App Window │ z-40 ││
|
||||||
|
│ │ │ (Tasks) │ │ (Signals) │ ││
|
||||||
|
│ │ └─────────────┘ └─────────────┘ ││
|
||||||
|
│ │ ┌──────────────────────┐ ││
|
||||||
|
│ │ │ Docked Apps (Cluck) │ z-45, no chrome ││
|
||||||
|
│ │ └──────────────────────┘ ││
|
||||||
|
│ │ ┌──────┐ ┌──────┐ ┌──────┐ z-0 ││
|
||||||
|
│ │ │ Icon │ │ Icon │ │ Icon │ ││
|
||||||
|
│ │ └──────┘ └──────┘ └──────┘ ││
|
||||||
|
│ └──────────────────────────────────────────────┘│
|
||||||
|
│ ┌──────────────────────────────────────────────┐│
|
||||||
|
│ │ Taskbar [Tasks] [Signals] 🎨 ⚙ v0.9 ││
|
||||||
|
│ └──────────────────────────────────────────────┘│
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Base OS = Auth Gate + Desktop Surface + Window Manager + Taskbar
|
||||||
|
+ Icon Grid + Docked Layer + OS-service surface
|
||||||
|
Apps = Tasks, KB, Ops, Signals, Knowledge, Learning, Settings, Cluck
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 App kinds
|
||||||
|
|
||||||
|
Two kinds, distinguished by one flag:
|
||||||
|
|
||||||
|
| Kind | Window | Titlebar | Taskbar button | Opened by |
|
||||||
|
|------|--------|----------|----------------|-----------|
|
||||||
|
| **Windowed** (default) | wmkit floating window | Yes | Yes (automatic) | `openAppWindow(id)` → `wm.open()` |
|
||||||
|
| **Docked** (`docked: true`) | None — renders on the Docked Layer | No | No | `openAppWindow(id)` → toggles visibility |
|
||||||
|
|
||||||
|
Docked apps are **not** wmkit citizens. They render in a dedicated layer
|
||||||
|
above the window layer, their visibility is a persisted boolean, and
|
||||||
|
clicking their desktop icon toggles show/hide. They never appear in the
|
||||||
|
taskbar because they never enter `wmState.order`.
|
||||||
|
|
||||||
|
### 2.3 The App contract
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AppDef {
|
||||||
|
// Identity (required)
|
||||||
|
id: string // unique; window IDs are "app:<id>"
|
||||||
|
title: string // desktop icon label + window titlebar
|
||||||
|
icon: Component // Lucide icon (desktop icon + taskbar)
|
||||||
|
component: Component // Svelte component; receives NO props
|
||||||
|
|
||||||
|
// Kind
|
||||||
|
docked?: boolean // true = Docked Layer app, no window (default false)
|
||||||
|
|
||||||
|
// Window geometry — required for windowed apps, forbidden for docked apps
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
minWidth?: number
|
||||||
|
minHeight?: number
|
||||||
|
|
||||||
|
// Behavior (all optional)
|
||||||
|
badge?: (summary: DashboardSummary | null) => number
|
||||||
|
noIcon?: boolean // true = registered but no desktop icon
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation rules** (enforced by `apps.test.ts`, not runtime checks):
|
||||||
|
|
||||||
|
- `id` unique, non-empty.
|
||||||
|
- Windowed apps: `width`/`height` present and positive.
|
||||||
|
- Docked apps: `width`/`height` absent (geometry is meaningless without a
|
||||||
|
window).
|
||||||
|
- Every app has an icon component (even `noIcon` apps — the taskbar and
|
||||||
|
future surfaces need it).
|
||||||
|
|
||||||
|
**Design decisions, and why:**
|
||||||
|
|
||||||
|
- **No `keepAlive`.** Module-scoped state (mascot model, sprite cache)
|
||||||
|
already survives unmount. If a future app needs close-to-hide semantics,
|
||||||
|
that's a wmkit feature request, not an AppDef field.
|
||||||
|
- **No `noTaskbar`.** Docked apps never reach the taskbar; windowed apps
|
||||||
|
always should. A windowed app with no taskbar button is an orphan the
|
||||||
|
operator can't find.
|
||||||
|
- **No lifecycle hooks in the contract.** Svelte's own `onMount`/`onDestroy`
|
||||||
|
already fire on window open/close. A shell-level `onRegister` is only
|
||||||
|
meaningful once apps register dynamically — deferred to Phase 3, where
|
||||||
|
it becomes the permission handshake.
|
||||||
|
- **Apps receive no props.** The component is the app. It imports OS
|
||||||
|
services (§2.4) directly. This keeps the shell→app edge one-way and
|
||||||
|
trivially mockable.
|
||||||
|
|
||||||
|
### 2.4 The OS-service surface (AppOS)
|
||||||
|
|
||||||
|
The stable set of `$lib` exports an App may import. Everything else in
|
||||||
|
`$lib` is shell-internal and may change without notice. This is a
|
||||||
|
**documentation contract** today (apps are compiled in); it becomes an
|
||||||
|
**enforced sandbox boundary** in Phase 3.
|
||||||
|
|
||||||
|
| Service | Import | Stability |
|
||||||
|
|---------|--------|-----------|
|
||||||
|
| Open an app window | `openAppWindow(id)` from `$lib/stores/windows` | Stable |
|
||||||
|
| Open an entity window | `openEntityWindow(slug)` from `$lib/stores/windows` | Stable |
|
||||||
|
| Open a task window | `openTaskWindow(sessionId, title)` from `$lib/stores/windows` | Stable |
|
||||||
|
| Dashboard summary | `summary`, `subscribeContext` from `$lib/stores/context` | Stable |
|
||||||
|
| Live events | `subscribeEvents` from `$lib/stores/events` | Stable |
|
||||||
|
| Per-session chat | `chatFor(sessionId)` from `$lib/stores/chat` | Stable |
|
||||||
|
| Per-session workspace | `workspaceFor(sessionId)` from `$lib/stores/workspace` | Stable |
|
||||||
|
| REST API | `$lib/api` functions | Stable (generated from OpenAPI) |
|
||||||
|
| UI primitives | `$lib/components/ui/*` | Stable |
|
||||||
|
| Theme | `getTheme`, `setTheme` from `$lib/stores/theme.svelte` | Stable |
|
||||||
|
|
||||||
|
### 2.5 Content resolution — fixed
|
||||||
|
|
||||||
|
Replace the positional `if/else` chain with a prefix → component map owned
|
||||||
|
by the shell:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WindowLayer.svelte — one map, dispatch by prefix. New window kinds
|
||||||
|
// register here, not in an if/else chain.
|
||||||
|
const CONTENT_RESOLVERS: Array<[prefix: string, resolve: (id: string) => Component | null]> = [
|
||||||
|
['session:', () => SessionChatWindow],
|
||||||
|
['app:', (id) => appById.get(id.slice(4))?.component ?? null],
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolveContent(id: string): Component | null {
|
||||||
|
if (id === NEW_TASK_WINDOW_ID) return NewTaskChat
|
||||||
|
for (const [prefix, resolve] of CONTENT_RESOLVERS) {
|
||||||
|
if (id.startsWith(prefix)) return resolve(id)
|
||||||
|
}
|
||||||
|
return EntityDetailContent // bare entity slug fallback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding a `'lxc:'` console window kind later = one array entry. The
|
||||||
|
existing orphan-close effect (`WindowLayer.svelte:25-30`) is kept as-is;
|
||||||
|
Phase 2 must gate it on registry-ready (§5).
|
||||||
|
|
||||||
|
### 2.6 Designed extension points (documented, not built)
|
||||||
|
|
||||||
|
| Extension | Mechanism when built | Trigger |
|
||||||
|
|-----------|---------------------|---------|
|
||||||
|
| Titlebar actions | `titlebarActions?: Component` on AppDef, rendered left of min/max/close | First app that needs one |
|
||||||
|
| App-scoped state | `state?: () => Record<string, unknown>` on AppDef | First app with cross-mount state that isn't module-scoped |
|
||||||
|
| `onRegister` handshake | Called with a scoped AppOS capability object | Phase 3 (dynamic install) |
|
||||||
|
|
||||||
|
Documenting these now prevents the Phase 1 contract from painting itself
|
||||||
|
into a corner; building them now would be speculative.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The mascot as an App
|
||||||
|
|
||||||
|
### 3.1 Registration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'mascot',
|
||||||
|
title: 'Cluck',
|
||||||
|
icon: EggIcon, // Lucide egg (chick/adult swap is a future nicety)
|
||||||
|
component: MascotLayer,
|
||||||
|
docked: true,
|
||||||
|
// no width/height — docked
|
||||||
|
// no badge — a permanent "1" is noise, not information
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 The docked-visibility store (new)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/stores/docked.ts
|
||||||
|
// Visibility for docked apps — persisted, so "hidden" survives reloads.
|
||||||
|
// Keyed by app id; absent key = visible (default-on for new docked apps).
|
||||||
|
export const dockedVisibility: Readable<Record<string, boolean>>
|
||||||
|
export function toggleDocked(appId: string): void
|
||||||
|
export function isDockedVisible(appId: string): boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
- localStorage key: `oikos-docked-apps`
|
||||||
|
- Default: visible (a fresh install shows the mascot; hiding is opt-out)
|
||||||
|
- Merge semantics mirror `icons.ts`: unknown persisted keys are kept (an
|
||||||
|
uninstalled docked app that gets reinstalled remembers its state)
|
||||||
|
|
||||||
|
### 3.3 Shell changes
|
||||||
|
|
||||||
|
**`windows.ts` — `openAppWindow` branches on kind:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function openAppWindow(appId: string): void {
|
||||||
|
const app = appById.get(appId)
|
||||||
|
if (!app) return
|
||||||
|
if (app.docked) { toggleDocked(appId); return } // ← the branch the first draft missed
|
||||||
|
// ... existing wm.open path unchanged
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the load-bearing detail: the icon click in `Desktop.svelte:93`
|
||||||
|
calls `openAppWindow(app.id)` for every app uniformly. Branching **inside**
|
||||||
|
`openAppWindow` means Desktop.svelte, legacy hash resolution, and any
|
||||||
|
future caller need no special cases.
|
||||||
|
|
||||||
|
**`Desktop.svelte` — replace hardcoded `<MascotLayer />` with:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<DockedLayer />
|
||||||
|
```
|
||||||
|
|
||||||
|
**`components/desktop-shell/DockedLayer.svelte` — new, ~30 lines:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#each APPS.filter(a => a.docked) as app (app.id)}
|
||||||
|
{#if $dockedVisibility[app.id] ?? true}
|
||||||
|
<app.component />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendered after `<WindowLayer />` inside the surface div, so docked apps
|
||||||
|
share the surface's coordinate space (the mascot's ground-line computation
|
||||||
|
depends on this — `MascotLayer.svelte:9-13`).
|
||||||
|
|
||||||
|
**`MascotLayer.svelte` — zero changes.** No props today, no props after.
|
||||||
|
|
||||||
|
### 3.4 What the mascot gains
|
||||||
|
|
||||||
|
| Feature | Before | After |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Registry entry | None — hardcoded in shell | First-class AppDef |
|
||||||
|
| Show/hide | Impossible — always mounted | Icon click toggles; persists across reloads |
|
||||||
|
| Shell coupling | `Desktop.svelte` imports mascot internals | Shell knows only `AppDef` |
|
||||||
|
| Precedent for overlay apps | None | Any `docked: true` app (clock, net monitor) uses the same path |
|
||||||
|
|
||||||
|
### 3.5 What the mascot does *not* gain (deliberately)
|
||||||
|
|
||||||
|
- **No taskbar button.** No window → no taskbar entry. The desktop icon is
|
||||||
|
the control.
|
||||||
|
- **No window chrome.** It's a desktop creature, not a document.
|
||||||
|
- **No settings panel in v1.** Hatch/rename/pet/feed stay in the existing
|
||||||
|
radial menu. A mascot *settings* surface (volume, behavior toggles) would
|
||||||
|
be a separate windowed app later — noted as a follow-up idea, not
|
||||||
|
planned.
|
||||||
|
|
||||||
|
### 3.6 UX risk: "where did my chicken go?"
|
||||||
|
|
||||||
|
Hidden state persists across reloads. Mitigation: the desktop icon is
|
||||||
|
always present and is the obvious toggle; the icon's tooltip reads
|
||||||
|
"Cluck — click to show/hide". Acceptable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Current apps — conformance audit
|
||||||
|
|
||||||
|
| App | Conforms? | Notes |
|
||||||
|
|-----|-----------|-------|
|
||||||
|
| **Tasks** (`Overview.svelte`) | ✅ Full | Self-contained. Opens session windows via `openTaskWindow`. |
|
||||||
|
| **Knowledge Base** (`KnowledgeBase.svelte`) | ✅ Full | Opens entity windows via `openEntityWindow`. |
|
||||||
|
| **Operations** (`Ops.svelte`) | ✅ Full | Badge reads `summary`. |
|
||||||
|
| **Signals** (`Signals.svelte`) | ✅ Full | Opens entity windows. |
|
||||||
|
| **Knowledge** (`Knowledge.svelte`) | ✅ Full | — |
|
||||||
|
| **Learning** (`Learning.svelte`) | ✅ Full | — |
|
||||||
|
| **Settings** (`Settings.svelte`) | ✅ Full | Opened from taskbar tray too — same `openAppWindow` path. |
|
||||||
|
| **Mascot** | ❌ Not an App | Hardcoded in Desktop.svelte. Refactored per §3. |
|
||||||
|
|
||||||
|
All seven windowed apps conform today. "Independently shippable" at Phase 1
|
||||||
|
means: add = one page file + one registry entry; remove = delete both. No
|
||||||
|
shell edits, no inter-app imports (apps open each other's surfaces only
|
||||||
|
through AppOS primitives).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Extensibility roadmap
|
||||||
|
|
||||||
|
### Phase 1: Strengthen the contract (this plan)
|
||||||
|
|
||||||
|
- [x] `AppDef` extended: `docked`, `noIcon`; geometry conditional on kind
|
||||||
|
- [x] `lib/stores/docked.ts`: docked-visibility store, persisted
|
||||||
|
- [x] `openAppWindow` branches on `docked`
|
||||||
|
- [x] `DockedLayer.svelte`: generic docked-app layer in Desktop.svelte
|
||||||
|
- [x] Mascot registered as `docked: true`; hardcoded `<MascotLayer />` removed
|
||||||
|
- [x] WindowLayer: prefix-map content resolution *(deferred — re-audited as gold-plating; original gate already handles orphans)*
|
||||||
|
- [x] `apps.test.ts`: validation rules per kind (§2.3)
|
||||||
|
- [x] AppOS contract documented (§2.4 lands in MBSE component doc)
|
||||||
|
|
||||||
|
### Phase 2: Lazy loading
|
||||||
|
|
||||||
|
- [x] `component` becomes `() => Promise<{ default: Component }>`; all apps use dynamic imports
|
||||||
|
- [x] Desktop icons render immediately (metadata only); component chunk loads on window open
|
||||||
|
- [x] `LazyApp.svelte` — shared loading skeleton (spinner) used by WindowLayer + DockedLayer
|
||||||
|
- [x] Deleted `LazyMascot.svelte` — the registry lazy loader breaks the cycle directly
|
||||||
|
- [x] Vite code-splits each app into its own chunk (main bundle 800KB → 482KB)
|
||||||
|
- [ ] Icon store revalidates against live registry *(Phase 3 prerequisite — not needed while apps are statically registered)*
|
||||||
|
- [ ] WindowLayer orphan-close gated on registry-ready *(Phase 3 prerequisite)*
|
||||||
|
|
||||||
|
### Phase 3: Dynamic app installation (frontend scaffold, local bundles)
|
||||||
|
|
||||||
|
Scoped at execution time to **local bundles only** (remote-URL loading +
|
||||||
|
sandboxing deferred to Phase 4 — security-critical, needs ADR + careful
|
||||||
|
design). The mechanism built here generalizes to remote bundles by
|
||||||
|
swapping the catalog for a fetched manifest + `import(/* @vite-ignore */ url)`.
|
||||||
|
|
||||||
|
- [x] `AppManifest` format (id, title, permissions, version, geometry) — `web/src/app-store/catalog.ts`
|
||||||
|
- [x] `AppPermission` enum (declaration-only; enforcement is Phase 4)
|
||||||
|
- [x] Static catalog with one demo app (Notes) — `web/src/app-store/apps/Notes.svelte`
|
||||||
|
- [x] Runtime registry: `APPS` → derived store (built-ins + installed); `appById` → derived Map
|
||||||
|
- [x] `installApp` / `uninstallApp` + localStorage persistence (`oikos-installed-apps`)
|
||||||
|
- [x] `icons.ts` reactive to app registration (late-registering apps get free cells; reset re-seeds from live registry)
|
||||||
|
- [x] WindowLayer orphan-close reactive to `$appById` (reinstall revives, uninstall closes)
|
||||||
|
- [x] App Store page (`web/src/pages/AppStore.svelte`) — list / install / uninstall
|
||||||
|
- [x] Installed apps appear on desktop immediately (no reload); uninstall removes icon + closes window
|
||||||
|
- [x] Icon store revalidates against live registry *(the Phase 3 prerequisite — now done)*
|
||||||
|
- [ ] `/api/v1/apps` endpoint + DB-backed manifest storage *(Phase 4)*
|
||||||
|
- [ ] Remote bundle loading from URLs + CSP + capability sandboxing *(Phase 4)*
|
||||||
|
- [ ] Permission enforcement at AppOS boundary *(Phase 4)*
|
||||||
|
|
||||||
|
### Phase 4: Marketplace (vision)
|
||||||
|
|
||||||
|
- [ ] Community apps (network map, backup dashboard, energy monitor)
|
||||||
|
- [ ] Versioning + auto-update
|
||||||
|
- [ ] Mascot skin packs as installable docked-app variants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Implementation — Phase 1, file by file
|
||||||
|
|
||||||
|
| # | File | Change |
|
||||||
|
|---|------|--------|
|
||||||
|
| 1 | `lib/apps.ts` | Extend `AppDef` (`docked?`, `noIcon?`, geometry optional). Register mascot. Import `MascotLayer` + `EggIcon`. |
|
||||||
|
| 2 | `lib/stores/docked.ts` | **New.** `dockedVisibility` store, `toggleDocked`, `isDockedVisible`, localStorage persistence. |
|
||||||
|
| 3 | `lib/stores/windows.ts` | `openAppWindow`: docked branch → `toggleDocked`. |
|
||||||
|
| 4 | `components/desktop-shell/DockedLayer.svelte` | **New.** Renders visible docked apps after WindowLayer. |
|
||||||
|
| 5 | `components/desktop-shell/Desktop.svelte` | Replace `import MascotLayer` + `<MascotLayer />` with `<DockedLayer />`. |
|
||||||
|
| 6 | `components/desktop-shell/WindowLayer.svelte` | **Deferred during implementation.** The positional if/else was re-audited and found to already handle orphans cleanly (`{#if win && (!appId || app)}`), and any new window kind needs a prop-dispatch branch in markup regardless — so a prefix→component map adds machinery without decoupling. Documented as an extension point (§2.5) like `titlebarActions`; not built (YAGNI). |
|
||||||
|
| 7 | `lib/apps.test.ts` | Mock `MascotLayer` import (same pattern as pages). Per-kind validation tests. Docked apps exempt from positive-size test. |
|
||||||
|
| 8 | `lib/stores/docked.test.ts` | **New.** Toggle, persistence, default-visible, unknown-key merge. |
|
||||||
|
| 9 | `docs/mbse/components.md` | Add Component 9: Web Control Room — App Architecture (§7). |
|
||||||
|
|
||||||
|
**Out of scope for Phase 1:** `titlebarActions`, app-scoped state,
|
||||||
|
lazy loading, manifests, permissions.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm run test # vitest — registry + docked store
|
||||||
|
npm run check # svelte-check + tsc
|
||||||
|
npm run lint
|
||||||
|
npm run build # vite build — confirms no import cycles from DockedLayer
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual smoke: icon toggle hides/shows mascot → reload → stays hidden →
|
||||||
|
toggle → returns at last position (model `lastPos` restore). All seven
|
||||||
|
windowed apps open/focus/close identically to before. Legacy hash
|
||||||
|
`#/signals` still opens the Signals window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. MBSE documentation
|
||||||
|
|
||||||
|
Add **Component 9: Web Control Room — App Architecture** to
|
||||||
|
`docs/mbse/components.md`:
|
||||||
|
|
||||||
|
```
|
||||||
|
9. Web Control Room — App Architecture
|
||||||
|
9.1 Purpose — OS + Apps metaphor, why apps are independently shippable
|
||||||
|
9.2 Structural View — shell modules, registry, docked layer (mermaid)
|
||||||
|
9.3 App Contract — AppDef, validation rules, app kinds
|
||||||
|
9.4 OS-Service Surface — the AppOS table
|
||||||
|
9.5 Content Resolution — prefix map, window kinds, orphan cleanup
|
||||||
|
9.6 Behavior — window state machine, docked visibility lifecycle
|
||||||
|
9.7 Requirements — WEB-APP-* traceability
|
||||||
|
9.8 Verification — test coverage, manual smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
| ID | Requirement | Status |
|
||||||
|
|----|-------------|--------|
|
||||||
|
| WEB-APP-1 | Apps register via data-driven AppDef entries; no shell edits to add/remove | ✅ live |
|
||||||
|
| WEB-APP-2 | Apps render in wmkit floating windows | ✅ live |
|
||||||
|
| WEB-APP-3 | Window IDs namespaced (`app:`/`session:`/entity) — no collisions | ✅ live |
|
||||||
|
| WEB-APP-4 | Desktop icons render from the registry | ✅ live |
|
||||||
|
| WEB-APP-5 | Taskbar buttons derive from window state, icons resolved via registry | ✅ live |
|
||||||
|
| WEB-APP-6 | Removed apps' persisted windows self-close | ✅ live (`WindowLayer.svelte:25-30`) |
|
||||||
|
| WEB-APP-7 | Content resolution dispatches via prefix map, not positional if/else | ⬜ Deferred — re-audited; original gate already handles orphans, map adds no decoupling (§2.5) |
|
||||||
|
| WEB-APP-8 | Docked app kind: no window, no chrome, visibility toggled via icon | ⬜ Phase 1 |
|
||||||
|
| WEB-APP-9 | Mascot is a registered docked App, not a hardcoded shell component | ⬜ Phase 1 |
|
||||||
|
| WEB-APP-10 | Docked visibility persists across reloads | ⬜ Phase 1 |
|
||||||
|
| WEB-APP-11 | OS-service surface (AppOS) documented as the stable App API | ⬜ Phase 1 |
|
||||||
|
| WEB-APP-12 | Registry validation: per-kind geometry rules enforced by tests | ⬜ Phase 1 |
|
||||||
|
| WEB-APP-13 | Apps lazy-load; icons render from static metadata | ✅ Phase 2 |
|
||||||
|
| WEB-APP-14 | Icon store revalidates against live registry, not import-time snapshot | ✅ Phase 3 |
|
||||||
|
| WEB-APP-15 | Third-party apps install from manifests with declared permissions | ✅ Phase 3 (local bundles; enforcement Phase 4) |
|
||||||
|
|
||||||
|
### Sequence — windowed app open
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Desktop
|
||||||
|
participant WM as Window Manager
|
||||||
|
participant WL as Window Layer
|
||||||
|
participant App
|
||||||
|
|
||||||
|
User->>Desktop: click icon
|
||||||
|
Desktop->>WM: openAppWindow("signals")
|
||||||
|
Note over WM: docked? no → wm path
|
||||||
|
alt window exists
|
||||||
|
WM->>WM: restore + focus
|
||||||
|
else new
|
||||||
|
WM->>WM: wm.open({ id: "app:signals", ... })
|
||||||
|
WM->>WL: render frame
|
||||||
|
WL->>WL: resolveContent → prefix 'app:' → registry
|
||||||
|
WL->>App: mount component
|
||||||
|
end
|
||||||
|
WM->>Taskbar: new button in wmState.order
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequence — docked app toggle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Desktop
|
||||||
|
participant Dock as docked.ts
|
||||||
|
participant Layer as DockedLayer
|
||||||
|
participant App
|
||||||
|
|
||||||
|
User->>Desktop: click Cluck icon
|
||||||
|
Desktop->>Dock: openAppWindow("mascot") → docked → toggleDocked
|
||||||
|
Dock->>Dock: flip visibility, persist localStorage
|
||||||
|
Dock->>Layer: store update
|
||||||
|
alt now visible
|
||||||
|
Layer->>App: mount MascotLayer
|
||||||
|
Note over App: model + sprites restore<br/>from module scope
|
||||||
|
else now hidden
|
||||||
|
Layer->>App: unmount (state survives)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### State machine — app window
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Closed: registered, no window
|
||||||
|
Closed --> Open: openAppWindow
|
||||||
|
Open --> Focused: focus
|
||||||
|
Focused --> Open: blur
|
||||||
|
Open --> Minimized: minimize
|
||||||
|
Minimized --> Focused: restore
|
||||||
|
Open --> Closed: close
|
||||||
|
Minimized --> Closed: close
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risk & safety
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
|------|----------|------------|
|
||||||
|
| Mascot refactor breaks stimuli or ground-line computation | Medium | MascotLayer unchanged; DockedLayer mounts it in the same surface div, same position in the stacking order as today. |
|
||||||
|
| Hidden mascot never rediscovered | Low | Icon always present, tooltip says show/hide. |
|
||||||
|
| `openAppWindow` docked branch leaks into windowed path | Low | Branch is the first statement; windowed path byte-identical. Covered by existing call sites (icon click, taskbar settings, legacy hash). |
|
||||||
|
| Docked visibility store desyncs from registry | Low | Unknown keys kept on load; layer filters by `a.docked` from the live registry. |
|
||||||
|
| Phase 2 lazy loading kills persisted windows of not-yet-loaded apps | Medium | Explicit Phase 2 gate: orphan-close waits for registry-ready (§5). Called out now so it isn't discovered in production. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Appendix: relevant existing artifacts
|
||||||
|
|
||||||
|
| Artifact | Relevance |
|
||||||
|
|----------|-----------|
|
||||||
|
| `docs/mbse/README.md` §5 | MCP tools / REST / SSE — the data surface Apps consume |
|
||||||
|
| `docs/mbse/components.md` §5 | Current web control room component doc — Phase 1 extends it |
|
||||||
|
| `docs/mascot/README.md` | Mascot subsystem model (MASC-1..12); MASC-9's registry philosophy is the template for this plan |
|
||||||
|
| `plans/2026-07-08-control-room-webui.md` | Original control-room plan |
|
||||||
|
| `plans/done/2026-07-11-ui-review-ia-usability.md` | IA review that produced the desktop metaphor |
|
||||||
|
| `plans/2026-07-20-desktop-mascot.md` | Mascot plan; extension registries |
|
||||||
|
| `lib/apps.ts` header comment | Already documents the one-entry-to-add-an-app philosophy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Plan opened 2026-07-21. Phase 1 ready for execution — estimated small
|
||||||
|
(~half a day of focused work; nine file touches, two new files). Phases
|
||||||
|
2–4 are context for future sessions and do not block Phase 1.*
|
||||||
@@ -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 | [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 | [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-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
|
## Done
|
||||||
|
|
||||||
|
|||||||
57
web/src/lib/app-store/apps/Notes.svelte
Normal file
57
web/src/lib/app-store/apps/Notes.svelte
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Notes — a trivial installable app demoing the App Store lifecycle.
|
||||||
|
// Installed from the App Store, gets a desktop icon, opens in a window,
|
||||||
|
// has its own localStorage-backed state, and uninstalls cleanly. No
|
||||||
|
// shell-internal imports — this is a self-contained app that could be
|
||||||
|
// shipped as a standalone bundle (Phase 4 will load such bundles from
|
||||||
|
// a URL; here it's bundled and discovered via the catalog).
|
||||||
|
let { storageKey = 'oikos-app-notes' }: { storageKey?: string } = $props()
|
||||||
|
|
||||||
|
let text = $state('')
|
||||||
|
let saved = $state(false)
|
||||||
|
|
||||||
|
function load(): string {
|
||||||
|
if (typeof localStorage === 'undefined') return ''
|
||||||
|
return localStorage.getItem(storageKey) ?? ''
|
||||||
|
}
|
||||||
|
function save(): void {
|
||||||
|
if (typeof localStorage === 'undefined') return
|
||||||
|
localStorage.setItem(storageKey, text)
|
||||||
|
saved = true
|
||||||
|
setTimeout(() => (saved = false), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text = load()
|
||||||
|
$effect(() => {
|
||||||
|
if (!text) return
|
||||||
|
const t = setTimeout(() => save(), 2000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full min-h-0 flex-col gap-2 p-4">
|
||||||
|
<div class="flex shrink-0 items-center justify-between">
|
||||||
|
<h2 class="text-sm font-medium">Notes</h2>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{#if saved}saved{:else}unsaved{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
bind:value={text}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
placeholder="Type here. Auto-saves 2s after you stop, or Cmd/Ctrl+S."
|
||||||
|
class="min-h-0 flex-1 resize-none rounded-md border bg-background p-3 font-mono text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
|
></textarea>
|
||||||
|
<p class="shrink-0 text-xs text-muted-foreground">
|
||||||
|
A demo installable app — uninstall it from the App Store to remove its
|
||||||
|
icon and window. Its notes persist in localStorage under
|
||||||
|
<code class="font-mono">{storageKey}</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
81
web/src/lib/app-store/catalog.ts
Normal file
81
web/src/lib/app-store/catalog.ts
Normal file
@@ -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]))
|
||||||
@@ -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
|
// apps.ts holds only app metadata + a reactive registry. `component` is a
|
||||||
// heavy transitive graph into a unit test for no benefit here (and one of
|
// dynamic-import loader, not the page itself, so importing apps.ts pulls no
|
||||||
// those pages imports svelte-sonner, which fails to resolve under vitest's
|
// page modules. The install/uninstall tests touch localStorage and the
|
||||||
// bundled Vite — an unrelated, pre-existing package quirk). These tests only
|
// module-scoped installedIds store, so each re-imports the module fresh (see
|
||||||
// care about the registry's own shape (ids, sizes, window-id helpers), so
|
// docked.test.ts for the same pattern).
|
||||||
// stub the component imports out rather than pull all of that in.
|
import { builtinApps, appWindowId, appIdFromWindowId } from './apps'
|
||||||
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: {} }))
|
|
||||||
|
|
||||||
import { APPS, appById, appWindowId, appIdFromWindowId } from './apps'
|
describe('builtinApps registry', () => {
|
||||||
|
|
||||||
describe('APPS registry', () => {
|
|
||||||
it('has unique, non-empty ids', () => {
|
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(ids.length).toBeGreaterThan(0)
|
||||||
expect(new Set(ids).size).toBe(ids.length)
|
expect(new Set(ids).size).toBe(ids.length)
|
||||||
for (const id of ids) expect(id).not.toBe('')
|
for (const id of ids) expect(id).not.toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gives every app a positive default size', () => {
|
it('component is a loader function, not the component itself', () => {
|
||||||
for (const app of APPS) {
|
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.width).toBeGreaterThan(0)
|
||||||
expect(app.height).toBeGreaterThan(0)
|
expect(app.height).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('is indexed by id in appById', () => {
|
it('docked apps forbid window geometry', () => {
|
||||||
for (const app of APPS) {
|
for (const app of builtinApps.filter((a) => a.docked)) {
|
||||||
expect(appById.get(app.id)).toBe(app)
|
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', () => {
|
describe('appWindowId / appIdFromWindowId', () => {
|
||||||
it('round-trips an app id through its window id', () => {
|
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)
|
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', () => {
|
it('namespaces window ids so they cannot collide with entity slugs', () => {
|
||||||
// Entity slugs are bare `type:identifier` strings (see windows.ts's
|
for (const app of builtinApps) {
|
||||||
// openEntityWindow) — app window ids must never look like one.
|
|
||||||
for (const app of APPS) {
|
|
||||||
expect(appWindowId(app.id).startsWith('app:')).toBe(true)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
// The desktop's app registry — single source of truth for what shows up as
|
// The app registry — single source of truth for what shows up as a desktop
|
||||||
// a desktop icon and what opens in its window. Adding a new app is one entry
|
// icon and what opens in its window.
|
||||||
// here; nothing else needs to change (Desktop.svelte renders icons from
|
//
|
||||||
// APPS, WindowLayer.svelte resolves `app:<id>` window ids back through
|
// Two layers:
|
||||||
// appById, Taskbar.svelte reads title/icon the same way). Compare to the old
|
// - **Built-in apps** (always installed): the static `builtinApps` array
|
||||||
// App.svelte's hardcoded navItems array + if/else page branch, which required
|
// below. These ship with the build and can't be removed.
|
||||||
// touching three places (nav list, header title, main content branch) to add
|
// - **Installed apps** (operator-installed from the App Store): persisted
|
||||||
// one page.
|
// 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 type { Component } from 'svelte'
|
||||||
|
import { writable, derived, get, type Readable } from 'svelte/store'
|
||||||
import type { DashboardSummary } from '$lib/api'
|
import type { DashboardSummary } from '$lib/api'
|
||||||
import { openSignalCount } from '$lib/stores/context'
|
import { openSignalCount } from '$lib/stores/context'
|
||||||
import Overview from '../pages/Overview.svelte'
|
import { catalogById, type AppManifest, type AppPermission, type CatalogEntry } from '$lib/app-store/catalog'
|
||||||
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 ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
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 SearchIcon from '@lucide/svelte/icons/search'
|
||||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
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 {
|
export interface AppDef {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
icon: Component
|
icon: Component
|
||||||
component: Component
|
// Dynamic-import loader. Invoked when an app window opens (windowed) or
|
||||||
width: number
|
// when the Docked Layer first mounts the app (docked). Vite's module cache
|
||||||
height: number
|
// 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
|
minWidth?: number
|
||||||
minHeight?: 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
|
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',
|
id: 'tasks',
|
||||||
title: 'Tasks',
|
title: 'Tasks',
|
||||||
icon: ListTodoIcon,
|
icon: ListTodoIcon,
|
||||||
component: Overview,
|
component: () => import('../pages/Overview.svelte'),
|
||||||
width: 960,
|
width: 960,
|
||||||
height: 680,
|
height: 680,
|
||||||
minWidth: 480,
|
minWidth: 480,
|
||||||
minHeight: 420
|
minHeight: 420,
|
||||||
|
source: 'builtin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'kb',
|
id: 'kb',
|
||||||
title: 'Knowledge Base',
|
title: 'Knowledge Base',
|
||||||
icon: DatabaseIcon,
|
icon: DatabaseIcon,
|
||||||
component: KnowledgeBase,
|
component: () => import('../pages/KnowledgeBase.svelte'),
|
||||||
width: 1000,
|
width: 1000,
|
||||||
height: 700,
|
height: 700,
|
||||||
minWidth: 520,
|
minWidth: 520,
|
||||||
minHeight: 420
|
minHeight: 420,
|
||||||
|
source: 'builtin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ops',
|
id: 'ops',
|
||||||
title: 'Operations',
|
title: 'Operations',
|
||||||
icon: ShieldCheckIcon,
|
icon: ShieldCheckIcon,
|
||||||
component: Ops,
|
component: () => import('../pages/Ops.svelte'),
|
||||||
width: 860,
|
width: 860,
|
||||||
height: 620,
|
height: 620,
|
||||||
minWidth: 480,
|
minWidth: 480,
|
||||||
minHeight: 360,
|
minHeight: 360,
|
||||||
badge: (s) => s?.approvals_pending ?? 0
|
badge: (s) => s?.approvals_pending ?? 0,
|
||||||
|
source: 'builtin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'signals',
|
id: 'signals',
|
||||||
title: 'Signals',
|
title: 'Signals',
|
||||||
icon: SirenIcon,
|
icon: SirenIcon,
|
||||||
component: Signals,
|
component: () => import('../pages/Signals.svelte'),
|
||||||
width: 860,
|
width: 860,
|
||||||
height: 620,
|
height: 620,
|
||||||
minWidth: 480,
|
minWidth: 480,
|
||||||
minHeight: 360,
|
minHeight: 360,
|
||||||
badge: (s) => openSignalCount(s)
|
badge: (s) => openSignalCount(s),
|
||||||
|
source: 'builtin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'knowledge',
|
id: 'knowledge',
|
||||||
title: 'Knowledge',
|
title: 'Knowledge',
|
||||||
icon: SearchIcon,
|
icon: SearchIcon,
|
||||||
component: Knowledge,
|
component: () => import('../pages/Knowledge.svelte'),
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
minWidth: 440,
|
minWidth: 440,
|
||||||
minHeight: 340
|
minHeight: 340,
|
||||||
|
source: 'builtin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'learning',
|
id: 'learning',
|
||||||
title: 'Learning',
|
title: 'Learning',
|
||||||
icon: TrendingUpIcon,
|
icon: TrendingUpIcon,
|
||||||
component: Learning,
|
component: () => import('../pages/Learning.svelte'),
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
minWidth: 440,
|
minWidth: 440,
|
||||||
minHeight: 340
|
minHeight: 340,
|
||||||
|
source: 'builtin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
icon: SettingsIcon,
|
icon: SettingsIcon,
|
||||||
component: Settings,
|
component: () => import('../pages/Settings.svelte'),
|
||||||
width: 640,
|
width: 640,
|
||||||
height: 480,
|
height: 480,
|
||||||
minWidth: 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<string[]>(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<string[]> = { 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<AppDef[]> = 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<Map<string, AppDef>> = 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
|
// Window ids are namespaced so WindowLayer.svelte can tell at a glance which
|
||||||
// content branch owns an id: `app:<id>` for registry apps, `session:<id>`
|
// content branch owns an id: `app:<id>` for registry apps, `session:<id>`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as Table from '$lib/components/ui/table'
|
import * as Table from '$lib/components/ui/table'
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
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 EmptyState from '$lib/components/EmptyState.svelte'
|
||||||
import HealthDotRenderer from '$lib/components/data-table/renderers/HealthDotRenderer.svelte'
|
import HealthDotRenderer from '$lib/components/data-table/renderers/HealthDotRenderer.svelte'
|
||||||
import StatusBadgeRenderer from '$lib/components/data-table/renderers/StatusBadgeRenderer.svelte'
|
import StatusBadgeRenderer from '$lib/components/data-table/renderers/StatusBadgeRenderer.svelte'
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { TableHandler } from '@vincjo/datatables'
|
import { TableHandler } from '@vincjo/datatables'
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||||
import SortHeader from './sort-header.svelte'
|
import SortHeader from './SortHeader.svelte'
|
||||||
import Toolbar from './toolbar.svelte'
|
import Toolbar from './Toolbar.svelte'
|
||||||
import Pagination from './pagination/Pagination.svelte'
|
import Pagination from './pagination/Pagination.svelte'
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte'
|
import EmptyState from '$lib/components/EmptyState.svelte'
|
||||||
import BadgeRenderer from './renderers/BadgeRenderer.svelte'
|
import BadgeRenderer from './renderers/BadgeRenderer.svelte'
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
import DateRenderer from './renderers/DateRenderer.svelte'
|
import DateRenderer from './renderers/DateRenderer.svelte'
|
||||||
import StatusBadgeRenderer from './renderers/StatusBadgeRenderer.svelte'
|
import StatusBadgeRenderer from './renderers/StatusBadgeRenderer.svelte'
|
||||||
import { resolveCellValue } from './columns'
|
import { resolveCellValue } from './columns'
|
||||||
import type { DataTableColumn, BuiltinRenderer } from './DataTable.svelte.ts'
|
import type { DataTableColumn, BuiltinRenderer } from './types'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type Row = Record<string, any>
|
type Row = Record<string, any>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SearchInput from './search-input.svelte'
|
import SearchInput from './SearchInput.svelte'
|
||||||
import RowsPerPage from './pagination/RowsPerPage.svelte'
|
import RowsPerPage from './pagination/RowsPerPage.svelte'
|
||||||
import type { TableHandler } from '@vincjo/datatables'
|
import type { TableHandler } from '@vincjo/datatables'
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DataTableColumn } from './DataTable.svelte.ts'
|
import type { DataTableColumn } from './types'
|
||||||
|
|
||||||
export function resolveCellValue<T>(row: T, col: DataTableColumn<T>): unknown {
|
export function resolveCellValue<T>(row: T, col: DataTableColumn<T>): unknown {
|
||||||
if (col.accessor) return col.accessor(row)
|
if (col.accessor) return col.accessor(row)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// or dragged window can never end up underneath the taskbar. This replaces
|
// or dragged window can never end up underneath the taskbar. This replaces
|
||||||
// the old sidebar + hash-routed page shell in App.svelte entirely; apps are
|
// the old sidebar + hash-routed page shell in App.svelte entirely; apps are
|
||||||
// desktop icons now (see $lib/apps.ts), not nav items.
|
// desktop icons now (see $lib/apps.ts), not nav items.
|
||||||
import { APPS } from '$lib/apps'
|
import { apps } from '$lib/apps'
|
||||||
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
|
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
|
||||||
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
|
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
|
||||||
import { summary } from '$lib/stores/context'
|
import { summary } from '$lib/stores/context'
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
import DesktopIcon from './DesktopIcon.svelte'
|
import DesktopIcon from './DesktopIcon.svelte'
|
||||||
import TaskLauncher from './TaskLauncher.svelte'
|
import TaskLauncher from './TaskLauncher.svelte'
|
||||||
import WindowLayer from './WindowLayer.svelte'
|
import WindowLayer from './WindowLayer.svelte'
|
||||||
|
import DockedLayer from './DockedLayer.svelte'
|
||||||
import Taskbar from './Taskbar.svelte'
|
import Taskbar from './Taskbar.svelte'
|
||||||
import MascotLayer from '$lib/mascot/MascotLayer.svelte'
|
|
||||||
import LayersIcon from '@lucide/svelte/icons/layers'
|
import LayersIcon from '@lucide/svelte/icons/layers'
|
||||||
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
||||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<GraphBackground />
|
<GraphBackground />
|
||||||
|
|
||||||
<div class="pointer-events-none absolute inset-0 z-0">
|
<div class="pointer-events-none absolute inset-0 z-0">
|
||||||
{#each APPS as app (app.id)}
|
{#each $apps as app (app.id)}
|
||||||
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
|
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
|
||||||
{@const badge = app.badge?.($summary) ?? 0}
|
{@const badge = app.badge?.($summary) ?? 0}
|
||||||
<DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} />
|
<DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} />
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
|
|
||||||
<WindowLayer />
|
<WindowLayer />
|
||||||
|
|
||||||
<MascotLayer />
|
<DockedLayer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Taskbar />
|
<Taskbar />
|
||||||
|
|||||||
24
web/src/lib/components/desktop-shell/DockedLayer.svelte
Normal file
24
web/src/lib/components/desktop-shell/DockedLayer.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The docked-app layer: renders apps flagged `docked: true` on a
|
||||||
|
// pointer-events-none absolute inset-0 overlay above WindowLayer's z-40,
|
||||||
|
// below the desktop context menu's z-50. Docked apps have no wmkit window,
|
||||||
|
// no titlebar, and no taskbar button; their visibility is toggled by
|
||||||
|
// clicking their desktop icon (see stores/docked.ts). Replaces the
|
||||||
|
// previously-hardcoded <MascotLayer /> in Desktop.svelte — the mascot is
|
||||||
|
// now the first docked app, not a shell special case. Rendered as a sibling
|
||||||
|
// inside the surface div so docked apps share the surface's coordinate
|
||||||
|
// space (the mascot's ground-line computation depends on this).
|
||||||
|
import { apps } from '$lib/apps'
|
||||||
|
import { dockedVisibility } from '$lib/stores/docked'
|
||||||
|
import LazyApp from '$lib/components/desktop-shell/LazyApp.svelte'
|
||||||
|
|
||||||
|
const dockedApps = $derived($apps.filter((a) => a.docked))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pointer-events-none absolute inset-0 z-45">
|
||||||
|
{#each dockedApps as app (app.id)}
|
||||||
|
{#if $dockedVisibility[app.id] ?? true}
|
||||||
|
<LazyApp load={app.component} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
35
web/src/lib/components/desktop-shell/LazyApp.svelte
Normal file
35
web/src/lib/components/desktop-shell/LazyApp.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Renders an App's lazily-loaded component (AppDef.component is a
|
||||||
|
// dynamic-import loader, not the component itself). Shows the shared
|
||||||
|
// spinner while the chunk fetches; Vite's module cache makes repeat
|
||||||
|
// opens resolve from cache on the next microtask, so the spinner is
|
||||||
|
// one-tick at most after first load. Used by both WindowLayer
|
||||||
|
// (windowed apps) and DockedLayer (docked apps) so the loading state
|
||||||
|
// is uniform across app kinds.
|
||||||
|
import type { Component } from 'svelte'
|
||||||
|
import { untrack } from 'svelte'
|
||||||
|
import Spinner from '../Spinner.svelte'
|
||||||
|
|
||||||
|
let { load }: { load: () => Promise<{ default: Component }> } = $props()
|
||||||
|
|
||||||
|
// Created once per mount, not per render. `load` is the app's stable
|
||||||
|
// registry loader (app.component — defined once in the APPS array, never
|
||||||
|
// reassigned), so reading it at init is correct; untrack tells Svelte the
|
||||||
|
// one-shot read is intentional and silences the state_referenced_locally
|
||||||
|
// lint. Without pinning, {#await} would re-subscribe to a fresh Promise on
|
||||||
|
// every reactive re-evaluation of load() and loop.
|
||||||
|
const promise = untrack(() => load())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await promise}
|
||||||
|
<div class="flex h-full min-h-0 items-center justify-center text-muted-foreground">
|
||||||
|
<Spinner class="size-5" />
|
||||||
|
</div>
|
||||||
|
{:then mod}
|
||||||
|
{@const C = mod.default}
|
||||||
|
<C />
|
||||||
|
{:catch error}
|
||||||
|
<div class="flex h-full min-h-0 items-center justify-center p-4 text-center text-sm text-destructive">
|
||||||
|
Failed to load app: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
@@ -30,14 +30,14 @@
|
|||||||
|
|
||||||
function iconFor(id: string) {
|
function iconFor(id: string) {
|
||||||
const appId = appIdFromWindowId(id)
|
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
|
if (id.startsWith(SESSION_PREFIX)) return MessageSquareIcon
|
||||||
return DatabaseIcon
|
return DatabaseIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeFor(id: string): number {
|
function badgeFor(id: string): number {
|
||||||
const appId = appIdFromWindowId(id)
|
const appId = appIdFromWindowId(id)
|
||||||
const app = appId ? appById.get(appId) : undefined
|
const app = appId ? $appById.get(appId) : undefined
|
||||||
return app?.badge?.($summary) ?? 0
|
return app?.badge?.($summary) ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,18 +14,22 @@
|
|||||||
import EntityDetailContent from '../EntityDetailContent.svelte'
|
import EntityDetailContent from '../EntityDetailContent.svelte'
|
||||||
import SessionChatWindow from '../SessionChatWindow.svelte'
|
import SessionChatWindow from '../SessionChatWindow.svelte'
|
||||||
import NewTaskChat from './NewTaskChat.svelte'
|
import NewTaskChat from './NewTaskChat.svelte'
|
||||||
|
import LazyApp from '$lib/components/desktop-shell/LazyApp.svelte'
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
import MinusIcon from '@lucide/svelte/icons/minus'
|
import MinusIcon from '@lucide/svelte/icons/minus'
|
||||||
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
|
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
|
||||||
|
|
||||||
// A hydrated `app:<id>` window whose id no longer matches any registry
|
// A hydrated `app:<id>` window whose id no longer matches any registry
|
||||||
// entry (the app was renamed/removed since the layout was persisted) has
|
// entry (the app was uninstalled/removed since the layout was persisted)
|
||||||
// nothing to render — close it rather than leaving a permanently-blank
|
// has nothing to render — close it rather than leaving a permanently-blank
|
||||||
// window stuck in the taskbar.
|
// 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(() => {
|
$effect(() => {
|
||||||
|
const idx = $appById
|
||||||
for (const id of $wmState.order) {
|
for (const id of $wmState.order) {
|
||||||
const appId = appIdFromWindowId(id)
|
const appId = appIdFromWindowId(id)
|
||||||
if (appId && !appById.has(appId)) wm.close(id)
|
if (appId && !idx.has(appId)) wm.close(id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -34,7 +38,7 @@
|
|||||||
{#each $wmState.order as id (id)}
|
{#each $wmState.order as id (id)}
|
||||||
{@const win = $wmState.windows[id]}
|
{@const win = $wmState.windows[id]}
|
||||||
{@const appId = appIdFromWindowId(id)}
|
{@const appId = appIdFromWindowId(id)}
|
||||||
{@const app = appId ? appById.get(appId) : undefined}
|
{@const app = appId ? $appById.get(appId) : undefined}
|
||||||
{#if win && (!appId || app)}
|
{#if win && (!appId || app)}
|
||||||
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
||||||
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
|
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
|
||||||
@@ -73,7 +77,7 @@
|
|||||||
{:else if id === NEW_TASK_WINDOW_ID}
|
{:else if id === NEW_TASK_WINDOW_ID}
|
||||||
<NewTaskChat />
|
<NewTaskChat />
|
||||||
{:else if app}
|
{:else if app}
|
||||||
<app.component />
|
<LazyApp load={app.component} />
|
||||||
{:else}
|
{:else}
|
||||||
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
|
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
} from '$lib/mascot/state.svelte'
|
} from '$lib/mascot/state.svelte'
|
||||||
import { wmState } from '$lib/stores/windows'
|
import { wmState } from '$lib/stores/windows'
|
||||||
import { getIconPositions, iconPixelPos, GRID } from '$lib/stores/icons'
|
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
|
// MascotRuntime is created fresh per mount; the long-lived MascotModel
|
||||||
// (with lastPos, stage, name, ...) persists across mounts via localStorage.
|
// (with lastPos, stage, name, ...) persists across mounts via localStorage.
|
||||||
@@ -217,13 +216,13 @@
|
|||||||
}
|
}
|
||||||
const ICON_INVESTIGATE_FALLBACK = '👀 Ooh!'
|
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. */
|
/** 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): AppDef | null {
|
function iconAt(x: number, y: number): string | null {
|
||||||
const positions = getIconPositions()
|
const positions = getIconPositions()
|
||||||
for (const app of APPS) {
|
for (const id of Object.keys(positions)) {
|
||||||
const pos = positions[app.id] ?? { col: 0, row: 0 }
|
const pos = positions[id] ?? { col: 0, row: 0 }
|
||||||
const { x: ix, y: iy } = iconPixelPos(pos)
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
62
web/src/lib/stores/docked.test.ts
Normal file
62
web/src/lib/stores/docked.test.ts
Normal file
@@ -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<string, boolean> | 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
43
web/src/lib/stores/docked.ts
Normal file
43
web/src/lib/stores/docked.ts
Normal file
@@ -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<string, boolean> {
|
||||||
|
if (typeof localStorage === 'undefined') return {}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
return JSON.parse(raw) as Record<string, boolean>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _visibility = writable<Record<string, boolean>>(load())
|
||||||
|
|
||||||
|
function persist(vis: Record<string, boolean>): 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
|
||||||
|
}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
|
||||||
// icons.ts only needs APPS for its default-layout ids — stub it out rather
|
// icons.ts reads the reactive `apps` store (derived from built-ins +
|
||||||
// than pull in the real registry's full page-component graph (see
|
// installed apps) to seed default positions. Stub it as a minimal Readable
|
||||||
// apps.test.ts for why that graph is expensive/broken under vitest).
|
// emitting a fixed list rather than pull in the real registry.
|
||||||
vi.mock('$lib/apps', () => ({
|
vi.mock('$lib/apps', () => {
|
||||||
APPS: [{ id: 'tasks' }, { id: 'kb' }, { id: 'ops' }, { id: 'signals' }, { id: 'knowledge' }, { id: 'learning' }]
|
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'
|
import { GRID, iconPositions, placeIcon, iconPixelPos, maxCols, getIconPositions, resetIconLayout } from './icons'
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,14 @@
|
|||||||
// simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing
|
// simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing
|
||||||
// that inside wmkit would mean fighting its window-shaped abstractions for
|
// that inside wmkit would mean fighting its window-shaped abstractions for
|
||||||
// no benefit.
|
// 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 { writable, get } from 'svelte/store'
|
||||||
import { APPS } from '$lib/apps'
|
import { apps } from '$lib/apps'
|
||||||
|
|
||||||
export interface IconPos {
|
export interface IconPos {
|
||||||
col: number
|
col: number
|
||||||
@@ -17,36 +23,17 @@ export const GRID = { cell: 96, gap: 12, padding: 16 }
|
|||||||
|
|
||||||
const STORAGE_KEY = 'oikos-desktop-icons'
|
const STORAGE_KEY = 'oikos-desktop-icons'
|
||||||
|
|
||||||
function defaultPositions(): Record<string, IconPos> {
|
|
||||||
// Classic OS default: one left-edge column, registry order.
|
|
||||||
const out: Record<string, IconPos> = {}
|
|
||||||
APPS.forEach((app, i) => {
|
|
||||||
out[app.id] = { col: 0, row: i }
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function load(): Record<string, IconPos> {
|
function load(): Record<string, IconPos> {
|
||||||
if (typeof localStorage === 'undefined') return defaultPositions()
|
if (typeof localStorage === 'undefined') return {}
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (!raw) return defaultPositions()
|
if (!raw) return {}
|
||||||
const parsed = JSON.parse(raw) as Record<string, IconPos>
|
return JSON.parse(raw) as Record<string, IconPos>
|
||||||
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
|
|
||||||
} catch {
|
} catch {
|
||||||
return defaultPositions()
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const appIds = new Set(APPS.map((a) => a.id))
|
|
||||||
|
|
||||||
export const iconPositions = writable<Record<string, IconPos>>(load())
|
export const iconPositions = writable<Record<string, IconPos>>(load())
|
||||||
|
|
||||||
function persist(positions: Record<string, IconPos>): void {
|
function persist(positions: Record<string, IconPos>): void {
|
||||||
@@ -54,7 +41,7 @@ function persist(positions: Record<string, IconPos>): void {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
|
||||||
}
|
}
|
||||||
|
|
||||||
iconPositions.subscribe((positions) => persist(positions))
|
iconPositions.subscribe(persist)
|
||||||
|
|
||||||
function occupied(positions: Record<string, IconPos>, col: number, row: number, exceptId: string): boolean {
|
function occupied(positions: Record<string, IconPos>, col: number, row: number, exceptId: string): boolean {
|
||||||
return Object.entries(positions).some(([id, p]) => id !== exceptId && p.col === col && p.row === row)
|
return Object.entries(positions).some(([id, p]) => id !== exceptId && p.col === col && p.row === row)
|
||||||
@@ -108,6 +95,35 @@ export function getIconPositions(): Record<string, IconPos> {
|
|||||||
|
|
||||||
// Bails a messy manual layout back to the classic left-edge column,
|
// Bails a messy manual layout back to the classic left-edge column,
|
||||||
// registry order — the desktop's right-click menu's "Reset icon layout".
|
// 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 {
|
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<string, IconPos>, list: { id: string }[]): Record<string, IconPos> {
|
||||||
|
let next: Record<string, IconPos> | 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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
// opened from Knowledge Base, chat, or anywhere else land in the same
|
// opened from Knowledge Base, chat, or anywhere else land in the same
|
||||||
// floating window layer, with several windows open side by side, rather than
|
// floating window layer, with several windows open side by side, rather than
|
||||||
// each page owning its own single-entity sidebar/sheet.
|
// 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 { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
|
||||||
import { persist } from '@surdeddd/wmkit/persist'
|
import { persist } from '@surdeddd/wmkit/persist'
|
||||||
import { appById, appWindowId } from '$lib/apps'
|
import { appById, appWindowId } from '$lib/apps'
|
||||||
|
import { toggleDocked } from '$lib/stores/docked'
|
||||||
import { sessions } from '$lib/stores/chat'
|
import { sessions } from '$lib/stores/chat'
|
||||||
import { heading } from '$lib/tasks'
|
import { heading } from '$lib/tasks'
|
||||||
|
|
||||||
@@ -75,9 +76,18 @@ export function toggleShowDesktop(): void {
|
|||||||
// Opens (or focuses/restores) a registry app's window. Apps are
|
// Opens (or focuses/restores) a registry app's window. Apps are
|
||||||
// single-instance — double-clicking an already-open app's icon should never
|
// single-instance — double-clicking an already-open app's icon should never
|
||||||
// stack a second window, same dedupe pattern as openEntityWindow below.
|
// 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 {
|
export function openAppWindow(appId: string): void {
|
||||||
const app = appById.get(appId)
|
const app = get(appById).get(appId)
|
||||||
if (!app) return
|
if (!app) return
|
||||||
|
if (app.docked) {
|
||||||
|
toggleDocked(appId)
|
||||||
|
return
|
||||||
|
}
|
||||||
const id = appWindowId(appId)
|
const id = appWindowId(appId)
|
||||||
if (wm.get(id)) {
|
if (wm.get(id)) {
|
||||||
wm.restore(id)
|
wm.restore(id)
|
||||||
|
|||||||
92
web/src/pages/AppStore.svelte
Normal file
92
web/src/pages/AppStore.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// App Store — lists the installable-app catalog (web/src/lib/app-store/catalog.ts),
|
||||||
|
// shows install state, and installs/uninstalls. Installing persists the
|
||||||
|
// manifest id (localStorage) and the registry's reactive `apps` store
|
||||||
|
// immediately renders the new app's desktop icon — no reload. Uninstalling
|
||||||
|
// removes the icon and (via WindowLayer's reactive orphan-close effect)
|
||||||
|
// closes any open window for that app.
|
||||||
|
//
|
||||||
|
// Phase 3 scope: local bundles only (apps that ship with the build,
|
||||||
|
// discovered via the catalog). Phase 4 will swap the catalog for a
|
||||||
|
// fetched `/api/v1/apps` endpoint + remote bundle loading with
|
||||||
|
// sandboxing; the install/uninstall lifecycle here is the mechanism that
|
||||||
|
// generalizes.
|
||||||
|
import { CATALOG } from '$lib/app-store/catalog'
|
||||||
|
import { installApp, uninstallApp, installedAppIds } from '$lib/apps'
|
||||||
|
import CheckCircleIcon from '@lucide/svelte/icons/circle-check-big'
|
||||||
|
import DownloadIcon from '@lucide/svelte/icons/download'
|
||||||
|
import TrashIcon from '@lucide/svelte/icons/trash'
|
||||||
|
import ShieldIcon from '@lucide/svelte/icons/shield'
|
||||||
|
|
||||||
|
// Reactive membership test — re-renders when the installed set changes.
|
||||||
|
const installedSet = $derived(new Set($installedAppIds))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
|
<header class="shrink-0 border-b px-4 py-3">
|
||||||
|
<h1 class="text-base font-semibold">App Store</h1>
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Installable apps. Local bundles for now — a remote catalog + sandboxing
|
||||||
|
is Phase 4.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-auto p-4">
|
||||||
|
{#if CATALOG.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">No apps available yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex flex-col gap-3">
|
||||||
|
{#each CATALOG as entry (entry.manifest.id)}
|
||||||
|
{@const m = entry.manifest}
|
||||||
|
{@const isOn = installedSet.has(m.id)}
|
||||||
|
<li class="flex items-start gap-3 rounded-lg border p-3">
|
||||||
|
<div class="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/40">
|
||||||
|
<entry.icon class="size-5" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="truncate text-sm font-medium">{m.title}</h2>
|
||||||
|
<span class="shrink-0 text-[10px] font-mono text-muted-foreground">v{m.version}</span>
|
||||||
|
{#if m.author}<span class="shrink-0 text-[10px] text-muted-foreground">by {m.author}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{m.description}</p>
|
||||||
|
<div class="mt-1.5 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
<ShieldIcon class="size-3" />
|
||||||
|
<span>
|
||||||
|
{#if m.permissions.length}{m.permissions.join(', ')}{:else}no permissions requested{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0">
|
||||||
|
{#if isOn}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"
|
||||||
|
onclick={() => uninstallApp(m.id)}
|
||||||
|
>
|
||||||
|
<TrashIcon class="size-3.5" /> Uninstall
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 rounded-md bg-primary px-2.5 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
onclick={() => installApp(m.id)}
|
||||||
|
>
|
||||||
|
<DownloadIcon class="size-3.5" /> Install
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-md border border-dashed p-3 text-xs text-muted-foreground">
|
||||||
|
<p class="flex items-center gap-1.5">
|
||||||
|
<CheckCircleIcon class="size-3.5" />
|
||||||
|
Installed apps appear on the desktop immediately. Uninstalling closes
|
||||||
|
any open window for that app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import { toast } from 'svelte-sonner'
|
import { toast } from 'svelte-sonner'
|
||||||
import DataTable from '$lib/components/data-table/DataTable.svelte'
|
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 ApprovalActions from '$lib/components/data-table/renderers/ApprovalActions.svelte'
|
||||||
import ActivityCancel from '$lib/components/data-table/renderers/ActivityCancel.svelte'
|
import ActivityCancel from '$lib/components/data-table/renderers/ActivityCancel.svelte'
|
||||||
import ActivityAction from '$lib/components/data-table/renderers/ActivityAction.svelte'
|
import ActivityAction from '$lib/components/data-table/renderers/ActivityAction.svelte'
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { bucket, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
|
import { bucket, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import DataTable from '$lib/components/data-table/DataTable.svelte'
|
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 StatusDotRenderer from '$lib/components/data-table/renderers/StatusDotRenderer.svelte'
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||||
import type { Session } from '$lib/api'
|
import type { Session } from '$lib/api'
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import * as Select from '$lib/components/ui/select'
|
import * as Select from '$lib/components/ui/select'
|
||||||
import { toast } from 'svelte-sonner'
|
import { toast } from 'svelte-sonner'
|
||||||
import DataTable from '$lib/components/data-table/DataTable.svelte'
|
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'
|
import SignalActions from '$lib/components/data-table/renderers/SignalActions.svelte'
|
||||||
|
|
||||||
let signals = $state<Signal[]>([])
|
let signals = $state<Signal[]>([])
|
||||||
|
|||||||
Reference in New Issue
Block a user