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>

View 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]))

View File

@@ -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
// heavy transitive graph into a unit test for no benefit here (and one of
// those pages imports svelte-sonner, which fails to resolve under vitest's
// bundled Vite — an unrelated, pre-existing package quirk). These tests only
// care about the registry's own shape (ids, sizes, window-id helpers), so
// stub the component imports out rather than pull all of that in.
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: {} }))
// apps.ts holds only app metadata + a reactive registry. `component` is a
// dynamic-import loader, not the page itself, so importing apps.ts pulls no
// page modules. The install/uninstall tests touch localStorage and the
// module-scoped installedIds store, so each re-imports the module fresh (see
// docked.test.ts for the same pattern).
import { builtinApps, appWindowId, appIdFromWindowId } from './apps'
import { APPS, appById, appWindowId, appIdFromWindowId } from './apps'
describe('APPS registry', () => {
describe('builtinApps registry', () => {
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(new Set(ids).size).toBe(ids.length)
for (const id of ids) expect(id).not.toBe('')
})
it('gives every app a positive default size', () => {
for (const app of APPS) {
it('component is a loader function, not the component itself', () => {
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.height).toBeGreaterThan(0)
}
})
it('is indexed by id in appById', () => {
for (const app of APPS) {
expect(appById.get(app.id)).toBe(app)
it('docked apps forbid window geometry', () => {
for (const app of builtinApps.filter((a) => a.docked)) {
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', () => {
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)
}
})
@@ -52,10 +62,74 @@ describe('appWindowId / appIdFromWindowId', () => {
})
it('namespaces window ids so they cannot collide with entity slugs', () => {
// Entity slugs are bare `type:identifier` strings (see windows.ts's
// openEntityWindow) — app window ids must never look like one.
for (const app of APPS) {
for (const app of builtinApps) {
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')
})
})

View File

@@ -1,21 +1,29 @@
// The desktop's app registry — single source of truth for what shows up as
// a desktop icon and what opens in its window. Adding a new app is one entry
// here; nothing else needs to change (Desktop.svelte renders icons from
// APPS, WindowLayer.svelte resolves `app:<id>` window ids back through
// appById, Taskbar.svelte reads title/icon the same way). Compare to the old
// App.svelte's hardcoded navItems array + if/else page branch, which required
// touching three places (nav list, header title, main content branch) to add
// one page.
// The app registry — single source of truth for what shows up as a desktop
// icon and what opens in its window.
//
// Two layers:
// - **Built-in apps** (always installed): the static `builtinApps` array
// below. These ship with the build and can't be removed.
// - **Installed apps** (operator-installed from the App Store): persisted
// 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 { writable, derived, get, type Readable } from 'svelte/store'
import type { DashboardSummary } from '$lib/api'
import { openSignalCount } from '$lib/stores/context'
import Overview from '../pages/Overview.svelte'
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 { catalogById, type AppManifest, type AppPermission, type CatalogEntry } from '$lib/app-store/catalog'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import DatabaseIcon from '@lucide/svelte/icons/database'
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 TrendingUpIcon from '@lucide/svelte/icons/trending-up'
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 {
id: string
title: string
icon: Component
component: Component
width: number
height: number
// Dynamic-import loader. Invoked when an app window opens (windowed) or
// when the Docked Layer first mounts the app (docked). Vite's module cache
// 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
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
// 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',
title: 'Tasks',
icon: ListTodoIcon,
component: Overview,
component: () => import('../pages/Overview.svelte'),
width: 960,
height: 680,
minWidth: 480,
minHeight: 420
minHeight: 420,
source: 'builtin'
},
{
id: 'kb',
title: 'Knowledge Base',
icon: DatabaseIcon,
component: KnowledgeBase,
component: () => import('../pages/KnowledgeBase.svelte'),
width: 1000,
height: 700,
minWidth: 520,
minHeight: 420
minHeight: 420,
source: 'builtin'
},
{
id: 'ops',
title: 'Operations',
icon: ShieldCheckIcon,
component: Ops,
component: () => import('../pages/Ops.svelte'),
width: 860,
height: 620,
minWidth: 480,
minHeight: 360,
badge: (s) => s?.approvals_pending ?? 0
badge: (s) => s?.approvals_pending ?? 0,
source: 'builtin'
},
{
id: 'signals',
title: 'Signals',
icon: SirenIcon,
component: Signals,
component: () => import('../pages/Signals.svelte'),
width: 860,
height: 620,
minWidth: 480,
minHeight: 360,
badge: (s) => openSignalCount(s)
badge: (s) => openSignalCount(s),
source: 'builtin'
},
{
id: 'knowledge',
title: 'Knowledge',
icon: SearchIcon,
component: Knowledge,
component: () => import('../pages/Knowledge.svelte'),
width: 800,
height: 600,
minWidth: 440,
minHeight: 340
minHeight: 340,
source: 'builtin'
},
{
id: 'learning',
title: 'Learning',
icon: TrendingUpIcon,
component: Learning,
component: () => import('../pages/Learning.svelte'),
width: 800,
height: 600,
minWidth: 440,
minHeight: 340
minHeight: 340,
source: 'builtin'
},
{
id: 'settings',
title: 'Settings',
icon: SettingsIcon,
component: Settings,
component: () => import('../pages/Settings.svelte'),
width: 640,
height: 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
// content branch owns an id: `app:<id>` for registry apps, `session:<id>`

View File

@@ -3,7 +3,7 @@
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
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 HealthDotRenderer from '$lib/components/data-table/renderers/HealthDotRenderer.svelte'
import StatusBadgeRenderer from '$lib/components/data-table/renderers/StatusBadgeRenderer.svelte'

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { TableHandler } from '@vincjo/datatables'
import { Skeleton } from '$lib/components/ui/skeleton'
import SortHeader from './sort-header.svelte'
import Toolbar from './toolbar.svelte'
import SortHeader from './SortHeader.svelte'
import Toolbar from './Toolbar.svelte'
import Pagination from './pagination/Pagination.svelte'
import EmptyState from '$lib/components/EmptyState.svelte'
import BadgeRenderer from './renderers/BadgeRenderer.svelte'
@@ -11,7 +11,7 @@
import DateRenderer from './renderers/DateRenderer.svelte'
import StatusBadgeRenderer from './renderers/StatusBadgeRenderer.svelte'
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
type Row = Record<string, any>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import SearchInput from './search-input.svelte'
import SearchInput from './SearchInput.svelte'
import RowsPerPage from './pagination/RowsPerPage.svelte'
import type { TableHandler } from '@vincjo/datatables'

View File

@@ -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 {
if (col.accessor) return col.accessor(row)

View File

@@ -5,7 +5,7 @@
// 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
// 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 { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
import { summary } from '$lib/stores/context'
@@ -13,8 +13,8 @@
import DesktopIcon from './DesktopIcon.svelte'
import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte'
import DockedLayer from './DockedLayer.svelte'
import Taskbar from './Taskbar.svelte'
import MascotLayer from '$lib/mascot/MascotLayer.svelte'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
@@ -87,7 +87,7 @@
<GraphBackground />
<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 badge = app.badge?.($summary) ?? 0}
<DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} />
@@ -102,7 +102,7 @@
<WindowLayer />
<MascotLayer />
<DockedLayer />
</div>
<Taskbar />

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

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

View File

@@ -30,14 +30,14 @@
function iconFor(id: string) {
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
return DatabaseIcon
}
function badgeFor(id: string): number {
const appId = appIdFromWindowId(id)
const app = appId ? appById.get(appId) : undefined
const app = appId ? $appById.get(appId) : undefined
return app?.badge?.($summary) ?? 0
}

View File

@@ -14,18 +14,22 @@
import EntityDetailContent from '../EntityDetailContent.svelte'
import SessionChatWindow from '../SessionChatWindow.svelte'
import NewTaskChat from './NewTaskChat.svelte'
import LazyApp from '$lib/components/desktop-shell/LazyApp.svelte'
import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
// A hydrated `app:<id>` window whose id no longer matches any registry
// entry (the app was renamed/removed since the layout was persisted) has
// nothing to render — close it rather than leaving a permanently-blank
// window stuck in the taskbar.
// entry (the app was uninstalled/removed since the layout was persisted)
// has nothing to render — close it rather than leaving a permanently-blank
// 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(() => {
const idx = $appById
for (const id of $wmState.order) {
const appId = appIdFromWindowId(id)
if (appId && !appById.has(appId)) wm.close(id)
if (appId && !idx.has(appId)) wm.close(id)
}
})
</script>
@@ -34,7 +38,7 @@
{#each $wmState.order as id (id)}
{@const win = $wmState.windows[id]}
{@const appId = appIdFromWindowId(id)}
{@const app = appId ? appById.get(appId) : undefined}
{@const app = appId ? $appById.get(appId) : undefined}
{#if win && (!appId || app)}
<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">
@@ -73,7 +77,7 @@
{:else if id === NEW_TASK_WINDOW_ID}
<NewTaskChat />
{:else if app}
<app.component />
<LazyApp load={app.component} />
{:else}
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
{/if}

View File

@@ -32,7 +32,6 @@
} from '$lib/mascot/state.svelte'
import { wmState } from '$lib/stores/windows'
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
// (with lastPos, stage, name, ...) persists across mounts via localStorage.
@@ -217,13 +216,13 @@
}
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. */
function iconAt(x: number, y: number): AppDef | null {
/** 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): string | null {
const positions = getIconPositions()
for (const app of APPS) {
const pos = positions[app.id] ?? { col: 0, row: 0 }
for (const id of Object.keys(positions)) {
const pos = positions[id] ?? { col: 0, row: 0 }
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
}

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)

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

View File

@@ -13,7 +13,7 @@
import { Badge } from '$lib/components/ui/badge'
import { toast } from 'svelte-sonner'
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 ActivityCancel from '$lib/components/data-table/renderers/ActivityCancel.svelte'
import ActivityAction from '$lib/components/data-table/renderers/ActivityAction.svelte'

View File

@@ -6,7 +6,7 @@
import { bucket, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { Button } from '$lib/components/ui/button'
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 PlusIcon from '@lucide/svelte/icons/plus'
import type { Session } from '$lib/api'

View File

@@ -7,7 +7,7 @@
import * as Select from '$lib/components/ui/select'
import { toast } from 'svelte-sonner'
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'
let signals = $state<Signal[]>([])