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:
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'
|
||||
|
||||
// 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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user