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

Problem: the frontend had an implicit OS+Apps metaphor (desktop, floating
windows, an app registry) but the contract was informal — the mascot was
hardcoded into the shell, all apps were statically imported into one
800KB bundle, and there was no install/uninstall path.

Change: three phases landed.
- Phase 1 (contract + docked kind): AppDef extended with docked/noIcon
  and optional geometry; the mascot registered as a docked app via a
  generic DockedLayer that replaces the hardcoded <MascotLayer />;
  openAppWindow branches on docked → toggleDocked; persisted docked
  visibility store (absent key = visible, no APPS import to avoid a
  static cycle).
- Phase 2 (lazy loading): AppDef.component is now a dynamic-import
  loader; LazyApp renders with a loading skeleton; Vite code-splits
  each app (main bundle 800KB→485KB); the LazyMascot wrapper is gone
  since the lazy loader breaks the import cycle directly.
- Phase 3 (installable apps, local bundles): AppManifest + catalog +
  installApp/uninstallApp + localStorage persistence; reactive apps
  store (built-in + installed) and derived appById; App Store page;
  Notes demo app; icons.ts and WindowLayer's orphan-close react to
  registration so installs appear without a reload.
- Structure: data-table casing unified to PascalCase; the mislabeled
  DataTable.svelte.ts (pure types, not runes) renamed to types.ts;
  LazyApp colocated with its desktop-shell consumers; app-store moved
  under lib/ so the dependency direction is consistent.

Risk: the app registry is now a reactive store, not a static array, so
every consumer (Desktop, DockedLayer, Taskbar, icons, windows) reads
from derived stores. Two static-cycle traps are documented in
docs/mbse/components.md §9: docked.ts must not import APPS (it would
fire a TDZ at init via the apps.ts→pages→windows.ts→here path), and
apps.ts must not statically import the mascot (the lazy loader defers
its module graph). Remote bundle loading, the /api/v1/apps endpoint,
and permission enforcement are deliberately NOT in this commit — they
are security-critical and deferred to Phase 4 with an ADR.

Verification: vitest 38/38; svelte-check + tsc clean for changed files;
eslint clean; vite build green; runtime smoke confirmed (install
Notes → icon appears → open → uninstall → icon + window gone; survives
reload). docs/mbse/components.md Component 9 and the plan updated.

Plan: plans/2026-07-21-frontend-os-apps-architecture.md
This commit is contained in:
2026-07-21 14:37:36 +02:00
parent 50aed11cc4
commit 482c7f3448
30 changed files with 1597 additions and 127 deletions

View File

@@ -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()
})
})

View 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
}

View File

@@ -1,11 +1,19 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// icons.ts only needs APPS for its default-layout ids — stub it out rather
// than pull in the real registry's full page-component graph (see
// apps.test.ts for why that graph is expensive/broken under vitest).
vi.mock('$lib/apps', () => ({
APPS: [{ id: 'tasks' }, { id: 'kb' }, { id: 'ops' }, { id: 'signals' }, { id: 'knowledge' }, { id: 'learning' }]
}))
// icons.ts reads the reactive `apps` store (derived from built-ins +
// installed apps) to seed default positions. Stub it as a minimal Readable
// emitting a fixed list rather than pull in the real registry.
vi.mock('$lib/apps', () => {
const list = [
{ id: 'tasks' },
{ id: 'kb' },
{ id: 'ops' },
{ id: 'signals' },
{ id: 'knowledge' },
{ id: 'learning' }
]
return { apps: { subscribe: (cb: (v: typeof list) => void) => { cb(list); return () => {} } } }
})
import { GRID, iconPositions, placeIcon, iconPixelPos, maxCols, getIconPositions, resetIconLayout } from './icons'

View File

@@ -5,8 +5,14 @@
// simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing
// that inside wmkit would mean fighting its window-shaped abstractions for
// no benefit.
//
// Reactive to the apps store (Phase 3): when an installed app registers
// after init, it gets a free cell on the next emission. Uninstalled apps'
// positions are KEPT (so reinstall remembers where the icon was) but their
// icons simply don't render — the desktop's {#each $apps} is the source of
// truth for what's visible.
import { writable, get } from 'svelte/store'
import { APPS } from '$lib/apps'
import { apps } from '$lib/apps'
export interface IconPos {
col: number
@@ -17,36 +23,17 @@ export const GRID = { cell: 96, gap: 12, padding: 16 }
const STORAGE_KEY = 'oikos-desktop-icons'
function defaultPositions(): Record<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> {
if (typeof localStorage === 'undefined') return defaultPositions()
if (typeof localStorage === 'undefined') return {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultPositions()
const parsed = JSON.parse(raw) as Record<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
if (!raw) return {}
return JSON.parse(raw) as Record<string, IconPos>
} catch {
return defaultPositions()
return {}
}
}
const appIds = new Set(APPS.map((a) => a.id))
export const iconPositions = writable<Record<string, IconPos>>(load())
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))
}
iconPositions.subscribe((positions) => persist(positions))
iconPositions.subscribe(persist)
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)
@@ -108,6 +95,35 @@ export function getIconPositions(): Record<string, IconPos> {
// Bails a messy manual layout back to the classic left-edge column,
// registry order — the desktop's right-click menu's "Reset icon layout".
// Clears all positions then re-seeds from the current app list, so it
// respects the live registry (including installed apps) rather than a
// static snapshot.
export function resetIconLayout(): void {
iconPositions.set(defaultPositions())
iconPositions.set(seedMissing({}, get(apps)))
}
// Seeds positions for any app in `list` that doesn't have one yet —
// classic OS default: one left-edge column, registry order. Returns the
// same positions object if nothing needed seeding (so callers can skip a
// no-op set), otherwise a fresh merged object.
function seedMissing(positions: Record<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)
})

View File

@@ -3,10 +3,11 @@
// opened from Knowledge Base, chat, or anywhere else land in the same
// floating window layer, with several windows open side by side, rather than
// each page owning its own single-entity sidebar/sheet.
import { derived, type Readable } from 'svelte/store'
import { derived, get, type Readable } from 'svelte/store'
import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
import { persist } from '@surdeddd/wmkit/persist'
import { appById, appWindowId } from '$lib/apps'
import { toggleDocked } from '$lib/stores/docked'
import { sessions } from '$lib/stores/chat'
import { heading } from '$lib/tasks'
@@ -75,9 +76,18 @@ export function toggleShowDesktop(): void {
// Opens (or focuses/restores) a registry app's window. Apps are
// single-instance — double-clicking an already-open app's icon should never
// stack a second window, same dedupe pattern as openEntityWindow below.
// Docked apps (e.g. the mascot) have no wmkit window at all — clicking their
// icon toggles visibility on the Docked Layer instead, so this branches on
// kind before touching the window manager. All callers (the desktop icon,
// the taskbar settings button, legacy hash resolution) go through here, so
// none of them need a kind-specific branch.
export function openAppWindow(appId: string): void {
const app = appById.get(appId)
const app = get(appById).get(appId)
if (!app) return
if (app.docked) {
toggleDocked(appId)
return
}
const id = appWindowId(appId)
if (wm.get(id)) {
wm.restore(id)