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,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>