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