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
130 lines
5.0 KiB
Svelte
130 lines
5.0 KiB
Svelte
<script lang="ts">
|
|
// Bottom taskbar: a real flex row in the page layout (not an overlay), so
|
|
// windows can never be dragged/maximized underneath it — see Desktop.svelte
|
|
// for how the window layer's bounds are scoped to the surface above this.
|
|
// Shows a button for every open window (not just minimized ones — compare
|
|
// to the old MinimizedWindowsBar, which only ever showed minimized windows
|
|
// and gave no way to see/switch between windows that were merely
|
|
// unfocused), plus a system tray for theme/connection/version.
|
|
import { wm, wmState, toggleShowDesktop, openAppWindow } from '$lib/stores/windows'
|
|
import { appById, appIdFromWindowId } from '$lib/apps'
|
|
import { summary } from '$lib/stores/context'
|
|
import { truncateMiddle } from '$lib/utils'
|
|
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
|
|
import { VERSION } from '$lib/version'
|
|
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
|
|
import DatabaseIcon from '@lucide/svelte/icons/database'
|
|
import PaletteIcon from '@lucide/svelte/icons/palette'
|
|
import SettingsIcon from '@lucide/svelte/icons/settings'
|
|
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid'
|
|
import XIcon from '@lucide/svelte/icons/x'
|
|
|
|
const SESSION_PREFIX = 'session:'
|
|
|
|
const buttons = $derived(
|
|
[...$wmState.order]
|
|
.map((id) => $wmState.windows[id])
|
|
.filter((w): w is NonNullable<typeof w> => !!w)
|
|
.sort((a, b) => a.openedSeq - b.openedSeq)
|
|
)
|
|
|
|
function iconFor(id: string) {
|
|
const appId = appIdFromWindowId(id)
|
|
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
|
|
return app?.badge?.($summary) ?? 0
|
|
}
|
|
|
|
function toggle(id: string, win: (typeof buttons)[number]) {
|
|
if (win.stage === 'minimized') {
|
|
wm.restore(id)
|
|
wm.focus(id)
|
|
} else if ($wmState.focusedId === id) {
|
|
wm.minimize(id)
|
|
} else {
|
|
wm.focus(id)
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
|
|
<button
|
|
type="button"
|
|
class="flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
onclick={toggleShowDesktop}
|
|
title="Show desktop"
|
|
>
|
|
<LayoutGridIcon class="size-4" />
|
|
</button>
|
|
|
|
<div class="h-6 w-px shrink-0 bg-border"></div>
|
|
|
|
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
|
{#each buttons as win (win.id)}
|
|
{@const Icon = iconFor(win.id)}
|
|
{@const badge = badgeFor(win.id)}
|
|
<div class="group/tb relative flex shrink-0">
|
|
<button
|
|
type="button"
|
|
data-taskbar-btn={win.id}
|
|
class="flex h-8 max-w-56 items-center gap-1.5 rounded-md border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
|
|
win.id && win.stage !== 'minimized'
|
|
? 'border-primary/50 bg-primary/10 text-foreground'
|
|
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage === 'minimized' ? 'opacity-60' : ''}"
|
|
onclick={() => toggle(win.id, win)}
|
|
title={win.title}
|
|
>
|
|
{#if Icon}<Icon class="size-3.5 shrink-0" />{/if}
|
|
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
|
|
{#if badge > 0}
|
|
<span class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="absolute -top-1.5 -right-1.5 hidden size-4 items-center justify-center rounded-full bg-muted-foreground/80 text-background hover:bg-destructive group-hover/tb:flex"
|
|
onclick={(e) => {
|
|
e.stopPropagation()
|
|
wm.close(win.id)
|
|
}}
|
|
aria-label="Close {win.title}"
|
|
>
|
|
<XIcon class="size-2.5" />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="h-6 w-px shrink-0 bg-border"></div>
|
|
|
|
<div class="flex shrink-0 items-center gap-0.5">
|
|
<button
|
|
type="button"
|
|
class="flex items-center justify-center gap-1.5 rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
onclick={() => toggleTheme()}
|
|
title="Cycle theme"
|
|
>
|
|
<PaletteIcon class="size-4" />
|
|
<span class="hidden text-xs sm:inline">{THEME_LABELS[getTheme()]}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
onclick={() => openAppWindow('settings')}
|
|
title="Settings"
|
|
>
|
|
<SettingsIcon class="size-4" />
|
|
</button>
|
|
<span class="px-1.5 text-[11px] text-muted-foreground select-none">{VERSION}</span>
|
|
</div>
|
|
</div>
|