Implements plans/2026-07-20-desktop-mascot.md. New code under web/src/lib/mascot/ (types/sprites/render/state/behavior/actions/ stimuli + Mascot/MascotLayer/RadialMenu/NameDialog components) plus CC0 sprite sheets at web/public/mascot/ (chicken + Onocentaur egg pack + reaction bubbles). MascotLayer is inserted into Desktop.svelte after WindowLayer; <2-line integration. Tamagotchi: egg -> chick -> adult lifecycle persisted to localStorage['oikos-mascot'] (debounced 300ms). Egg hatches on first naming (no timed incubation per implementation deviation). Chick/adult wander, peck, sleep, blink autonomously via a weighted-random FSM; the chicken walks above windows (ground line = highest window top edge beneath its x, recomputed each tick from wmState; rides the ground when the window beneath is dragged). Interaction: draggable with flutter-fall physics on release mid-air; plain click = pet (heart bubble + happy anim); right-click opens a rounded-button radial menu (Interact/Care/Identity/Debug nested groups) mirroring the desktop's own right-click menu styling; auto-flips above/ left near screen edges. Awareness: stimulus bus subscribes to chat.ts streaming, activity.ts activityLog (knowledge-entry diff), events.ts liveEvents (critical/ signal -> alarmed, execution -> happy), with priority+cooldown gating. Egg-stage reactions are suppressed. Reaction bubbles are anti-aliased. Sprite loop runs at ~60fps via setTimeout (not rAF) per GraphBackground convention, dt clamped to 100ms; position via transform: translate3d + will-change: transform for compositor-friendly motion. Z-index ordering: WindowLayer z-40 < MascotLayer z-[45] < desktop context menu z-50 < RadialMenu/NameDialog z-[60]. Docs: plan + docs/mascot/README.md (MBSE subsystem model) updated to Implemented with a deviations note covering hatch-on-naming, PNG-sheet art, button-column radial menu, 60fps loop, egg-reaction suppression, and window-walking ground model. VERSION bumped 0.7.13 -> 0.8.0.
165 lines
6.2 KiB
Svelte
165 lines
6.2 KiB
Svelte
<script lang="ts">
|
|
// The desktop shell: full-viewport surface (background + icons + the
|
|
// centered task launcher + the floating window layer) with the taskbar
|
|
// docked below it as a real flex sibling, not an overlay — so a maximized
|
|
// 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 { iconPositions, resetIconLayout } from '$lib/stores/icons'
|
|
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
|
|
import { summary } from '$lib/stores/context'
|
|
import GraphBackground from '../GraphBackground.svelte'
|
|
import DesktopIcon from './DesktopIcon.svelte'
|
|
import TaskLauncher from './TaskLauncher.svelte'
|
|
import WindowLayer from './WindowLayer.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'
|
|
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
|
|
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
|
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
|
|
|
// Clicking the bare desktop (not an icon, not a window) blurs the focused
|
|
// window — the familiar "click empty desktop to deselect" affordance.
|
|
function onSurfaceClick(e: MouseEvent) {
|
|
if (e.currentTarget === e.target) wm.blur()
|
|
}
|
|
|
|
// Right-click menu, bare desktop only (same currentTarget===target gate as
|
|
// onSurfaceClick above — icons and windows sit on pointer-events-auto
|
|
// layers above the otherwise pointer-events-none surface, so a right-click
|
|
// that lands on either of them never reaches here). canUndo/canRedo are
|
|
// plain wmkit method calls (not stores), so they're snapshotted once at
|
|
// open time rather than read reactively in the template.
|
|
let menuPos = $state<{ x: number; y: number } | null>(null)
|
|
let menuCanUndo = $state(false)
|
|
let menuCanRedo = $state(false)
|
|
|
|
function onSurfaceContextMenu(e: MouseEvent) {
|
|
if (e.currentTarget !== e.target) return
|
|
e.preventDefault()
|
|
menuCanUndo = wm.canUndo()
|
|
menuCanRedo = wm.canRedo()
|
|
menuPos = { x: e.clientX, y: e.clientY }
|
|
}
|
|
|
|
function closeMenu() {
|
|
menuPos = null
|
|
}
|
|
|
|
function runMenuAction(fn: () => void) {
|
|
fn()
|
|
closeMenu()
|
|
}
|
|
|
|
// Cmd/Ctrl+Z / Shift+Z for window-arrangement undo/redo (move, resize,
|
|
// close, ...) — wmkit tracks this history but ships no default keybinding.
|
|
// Skipped entirely while an editable element has focus so it never
|
|
// fights the browser's own text-undo inside the task input or a form
|
|
// field.
|
|
function onWindowKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape' && menuPos) {
|
|
closeMenu()
|
|
return
|
|
}
|
|
const target = e.target as HTMLElement | null
|
|
const editable = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
|
if (editable) return
|
|
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return
|
|
e.preventDefault()
|
|
if (e.shiftKey) wm.redo()
|
|
else wm.undo()
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
|
|
|
|
<div class="fixed inset-0 flex flex-col">
|
|
<div
|
|
class="relative min-h-0 flex-1 overflow-hidden"
|
|
role="presentation"
|
|
onclick={onSurfaceClick}
|
|
oncontextmenu={onSurfaceContextMenu}
|
|
>
|
|
<GraphBackground />
|
|
|
|
<div class="pointer-events-none absolute inset-0 z-0">
|
|
{#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)} />
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6">
|
|
<div class="pointer-events-auto">
|
|
<TaskLauncher />
|
|
</div>
|
|
</div>
|
|
|
|
<WindowLayer />
|
|
|
|
<MascotLayer />
|
|
</div>
|
|
|
|
<Taskbar />
|
|
</div>
|
|
|
|
{#if menuPos}
|
|
<div
|
|
class="fixed z-50 min-w-48 rounded-md border bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
|
style="left: {menuPos.x}px; top: {menuPos.y}px"
|
|
role="menu"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
|
onclick={() => runMenuAction(() => wm.arrange('cascade'))}
|
|
>
|
|
<LayersIcon class="size-4" /> Cascade windows
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
|
onclick={() => runMenuAction(() => wm.arrange('tile'))}
|
|
>
|
|
<Rows3Icon class="size-4" /> Tile windows
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
|
onclick={() => runMenuAction(toggleShowDesktop)}
|
|
>
|
|
<MonitorIcon class="size-4" /> Show desktop
|
|
</button>
|
|
<div class="my-1 h-px bg-border"></div>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
|
onclick={() => runMenuAction(resetIconLayout)}
|
|
>
|
|
<RotateCcwIcon class="size-4" /> Reset icon layout
|
|
</button>
|
|
<div class="my-1 h-px bg-border"></div>
|
|
<button
|
|
type="button"
|
|
disabled={!menuCanUndo}
|
|
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
onclick={() => runMenuAction(() => wm.undo())}
|
|
>
|
|
<Undo2Icon class="size-4" /> Undo
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!menuCanRedo}
|
|
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
onclick={() => runMenuAction(() => wm.redo())}
|
|
>
|
|
<Redo2Icon class="size-4" /> Redo
|
|
</button>
|
|
</div>
|
|
{/if}
|