+
+
+ wm.blur()}
+ >
+
+ wm.arrange('cascade')}>
+ Cascade windows
+
+ wm.arrange('tile')}>
+ Tile windows
+
+
+ Show desktop
+
+
+
+ Reset icon layout
+
+
+ wm.undo()}>
+ Undo
+
+ wm.redo()}>
+ Redo
+
+
+
+
{#each $apps as app (app.id)}
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
@@ -107,58 +116,3 @@
-
-{#if menuPos}
-
-
runMenuAction(() => wm.arrange('cascade'))}
- >
- Cascade windows
-
-
runMenuAction(() => wm.arrange('tile'))}
- >
- Tile windows
-
-
runMenuAction(toggleShowDesktop)}
- >
- Show desktop
-
-
-
runMenuAction(resetIconLayout)}
- >
- Reset icon layout
-
-
-
runMenuAction(() => wm.undo())}
- >
- Undo
-
-
runMenuAction(() => wm.redo())}
- >
- Redo
-
-
-{/if}
diff --git a/web/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte b/web/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte
new file mode 100644
index 0000000..9fdc99f
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte
@@ -0,0 +1,41 @@
+
+
+
+ {#snippet children({ checked })}
+
+ {#if checked}
+
+ {/if}
+
+ {@render childrenProp?.()}
+ {/snippet}
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-content.svelte b/web/src/lib/components/ui/context-menu/context-menu-content.svelte
new file mode 100644
index 0000000..0c6238c
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-content.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-group-heading.svelte b/web/src/lib/components/ui/context-menu/context-menu-group-heading.svelte
new file mode 100644
index 0000000..0c0f459
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-group-heading.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-group.svelte b/web/src/lib/components/ui/context-menu/context-menu-group.svelte
new file mode 100644
index 0000000..c7c1e06
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-group.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-item.svelte b/web/src/lib/components/ui/context-menu/context-menu-item.svelte
new file mode 100644
index 0000000..58de12e
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-item.svelte
@@ -0,0 +1,27 @@
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-label.svelte b/web/src/lib/components/ui/context-menu/context-menu-label.svelte
new file mode 100644
index 0000000..4f26fc1
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-label.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-portal.svelte b/web/src/lib/components/ui/context-menu/context-menu-portal.svelte
new file mode 100644
index 0000000..96b1e3e
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-radio-group.svelte b/web/src/lib/components/ui/context-menu/context-menu-radio-group.svelte
new file mode 100644
index 0000000..964cb55
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-radio-group.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-radio-item.svelte b/web/src/lib/components/ui/context-menu/context-menu-radio-item.svelte
new file mode 100644
index 0000000..74663a3
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-radio-item.svelte
@@ -0,0 +1,35 @@
+
+
+
+ {#snippet children({ checked })}
+
+ {#if checked}
+
+ {/if}
+
+ {@render childrenProp?.({ checked })}
+ {/snippet}
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-separator.svelte b/web/src/lib/components/ui/context-menu/context-menu-separator.svelte
new file mode 100644
index 0000000..7f5b237
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-separator.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-shortcut.svelte b/web/src/lib/components/ui/context-menu/context-menu-shortcut.svelte
new file mode 100644
index 0000000..3305abc
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-shortcut.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-sub-content.svelte b/web/src/lib/components/ui/context-menu/context-menu-sub-content.svelte
new file mode 100644
index 0000000..b14b6da
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-sub-content.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte b/web/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte
new file mode 100644
index 0000000..37ef36d
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-sub.svelte b/web/src/lib/components/ui/context-menu/context-menu-sub.svelte
new file mode 100644
index 0000000..a03827b
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-sub.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu-trigger.svelte b/web/src/lib/components/ui/context-menu/context-menu-trigger.svelte
new file mode 100644
index 0000000..f6a1542
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu-trigger.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/context-menu.svelte b/web/src/lib/components/ui/context-menu/context-menu.svelte
new file mode 100644
index 0000000..cfaefb3
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/context-menu.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/web/src/lib/components/ui/context-menu/index.ts b/web/src/lib/components/ui/context-menu/index.ts
new file mode 100644
index 0000000..cbeaee1
--- /dev/null
+++ b/web/src/lib/components/ui/context-menu/index.ts
@@ -0,0 +1,52 @@
+import Root from "./context-menu.svelte";
+import Sub from "./context-menu-sub.svelte";
+import Portal from "./context-menu-portal.svelte";
+import Trigger from "./context-menu-trigger.svelte";
+import Group from "./context-menu-group.svelte";
+import RadioGroup from "./context-menu-radio-group.svelte";
+import Item from "./context-menu-item.svelte";
+import GroupHeading from "./context-menu-group-heading.svelte";
+import Content from "./context-menu-content.svelte";
+import Shortcut from "./context-menu-shortcut.svelte";
+import RadioItem from "./context-menu-radio-item.svelte";
+import Separator from "./context-menu-separator.svelte";
+import SubContent from "./context-menu-sub-content.svelte";
+import SubTrigger from "./context-menu-sub-trigger.svelte";
+import CheckboxItem from "./context-menu-checkbox-item.svelte";
+import Label from "./context-menu-label.svelte";
+
+export {
+ Root,
+ Sub,
+ Portal,
+ Item,
+ GroupHeading,
+ Label,
+ Group,
+ Trigger,
+ Content,
+ Shortcut,
+ Separator,
+ RadioItem,
+ SubContent,
+ SubTrigger,
+ RadioGroup,
+ CheckboxItem,
+ //
+ Root as ContextMenu,
+ Sub as ContextMenuSub,
+ Portal as ContextMenuPortal,
+ Item as ContextMenuItem,
+ GroupHeading as ContextMenuGroupHeading,
+ Group as ContextMenuGroup,
+ Content as ContextMenuContent,
+ Trigger as ContextMenuTrigger,
+ Shortcut as ContextMenuShortcut,
+ RadioItem as ContextMenuRadioItem,
+ Separator as ContextMenuSeparator,
+ RadioGroup as ContextMenuRadioGroup,
+ SubContent as ContextMenuSubContent,
+ SubTrigger as ContextMenuSubTrigger,
+ CheckboxItem as ContextMenuCheckboxItem,
+ Label as ContextMenuLabel,
+};
diff --git a/web/src/lib/mascot/Mascot.svelte b/web/src/lib/mascot/Mascot.svelte
index 1838b2d..9706783 100644
--- a/web/src/lib/mascot/Mascot.svelte
+++ b/web/src/lib/mascot/Mascot.svelte
@@ -41,12 +41,10 @@
// unless the prop is declared bindable and the parent uses bind:runtime.
let {
runtime = $bindable(),
- onContextMenu,
onPet,
onRequestName
}: {
runtime: MascotRuntime
- onContextMenu: (screenX: number, screenY: number) => void
onPet: () => void
/** Called on a plain click while the mascot is a not-yet-named egg — reopens the naming dialog (see NameDialog's Escape-dismiss). */
onRequestName: () => void
@@ -548,12 +546,6 @@
endDragTracking(e.pointerId, canvas)
}
- function handleContextMenu(e: MouseEvent) {
- e.preventDefault()
- e.stopPropagation()
- onContextMenu(e.clientX, e.clientY)
- }
-
function syncCanvasSize() {
if (!canvas) return
canvas.width = CANVAS_W
@@ -757,7 +749,6 @@
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
- oncontextmenu={handleContextMenu}
title={model.name ?? 'Cluck'}
>
diff --git a/web/src/lib/mascot/MascotLayer.svelte b/web/src/lib/mascot/MascotLayer.svelte
index cbfc53c..88ecbc4 100644
--- a/web/src/lib/mascot/MascotLayer.svelte
+++ b/web/src/lib/mascot/MascotLayer.svelte
@@ -14,8 +14,10 @@
import { onMount } from 'svelte'
import Mascot from './Mascot.svelte'
- import RadialMenu from './RadialMenu.svelte'
+ import MascotMenu from './MascotMenu.svelte'
import NameDialog from './NameDialog.svelte'
+ import * as ContextMenu from '$lib/components/ui/context-menu'
+ import { MASCOT_ACTIONS } from './actions'
import { attachStimuli } from './stimuli'
import {
initMascotState,
@@ -60,20 +62,11 @@
busyTalking: false
})
- let menuPos = $state<{ x: number; y: number } | null>(null)
let nameDialogOpen = $state(false)
let nameDialogMode = $state<'hatch' | 'rename'>('hatch')
const model = $derived(getModel())
- function openMenu(x: number, y: number) {
- menuPos = { x, y }
- }
-
- function closeMenu() {
- menuPos = null
- }
-
function requestRename() {
nameDialogMode = 'rename'
nameDialogOpen = true
@@ -103,10 +96,11 @@
}
function refresh() {
- // Trigger reactivity: the menu reads model visibility predicates on
- // each render; touching a $state value re-runs the menu's derived
- // filter. menuPos re-assignment is a no-op if already set.
- menuPos = menuPos ? { ...menuPos } : null
+ // No-op. The old manual menu needed a nudge to re-run its visibility
+ // filter; the shadcn context-menu content is reactive off ctx.model
+ // (a $state object), so action mutations (happiness, stage, ...) are
+ // reflected live while the menu is open. Kept on actionCtx so leaf
+ // action signatures stay stable.
}
function onPet() {
@@ -216,25 +210,29 @@
- {
- nameDialogMode = 'hatch'
- nameDialogOpen = true
- }}
- />
+
+
+
+ {
+ nameDialogMode = 'hatch'
+ nameDialogOpen = true
+ }}
+ />
+
+
+
+
+
-{#if menuPos}
-
-{/if}
-
{#if nameDialogOpen}
+ // Renders the mascot action tree (actions.ts MASCOT_ACTIONS) as a shadcn
+ // context menu. Children → nested ContextMenu.Sub (native hover/click
+ // sub-menu navigation, replacing the old manual breadcrumb stack in
+ // RadialMenu.svelte); leaves → ContextMenu.Item with onSelect. The
+ // content portals to via bits-ui (see
+ // ui/context-menu/context-menu-content.svelte), so it escapes the
+ // mascot layer's pointer-events-none root — the old menu was
+ // non-interactive precisely because it rendered inside that root
+ // without re-enabling pointer-events. Visibility predicates are read
+ // reactively off ctx.model, so an action's visibility updates live while
+ // the menu is open (e.g. "Rename" disappears once the egg hatches).
+ //
+ // Recursive: a node with children renders a Sub whose SubContent recurses
+ // into this same component. Svelte resolves the self-import lazily, so
+ // the cycle is fine.
+ import * as ContextMenu from '$lib/components/ui/context-menu'
+ import type { MascotActionCtx, RadialAction } from './types'
+ // Self-import for recursive rendering of nested action groups.
+ import MascotMenu from './MascotMenu.svelte'
+
+ let { actions, ctx }: { actions: RadialAction[]; ctx: MascotActionCtx } = $props()
+
+ const visible = $derived(actions.filter((a) => !a.visible || a.visible(ctx.model)))
+
+
+{#each visible as a (a.id)}
+ {#if a.children && a.children.length > 0}
+
+
+ {#if a.icon} {/if}
+ {a.label}
+
+
+
+
+
+ {:else}
+ {
+ a.action?.(ctx)
+ }}
+ >
+ {#if a.icon} {/if}
+
+ {a.label}
+ {#if a.description}
+ {a.description}
+ {/if}
+
+
+ {/if}
+{/each}
diff --git a/web/src/lib/mascot/RadialMenu.svelte b/web/src/lib/mascot/RadialMenu.svelte
deleted file mode 100644
index 143113f..0000000
--- a/web/src/lib/mascot/RadialMenu.svelte
+++ /dev/null
@@ -1,157 +0,0 @@
-
-
-
-
-
-
- e.stopPropagation()}
- onkeydown={(e) => {
- if (e.key === 'Escape') {
- e.preventDefault()
- if (stack.length > 1) stack = stack.slice(0, -1)
- else onDismiss()
- }
- }}
->
- {#if depth > 1}
-
- Back
-
-
- {/if}
-
- {#each visibleItems as a (a.id)}
-
selectItem(a, ev)}
- oncontextmenu={(ev) => ev.preventDefault()}
- >
-
- {#if a.icon}
-
- {/if}
-
- {a.label}
- {#if a.description}
- {a.description}
- {/if}
-
-
- {#if a.children && a.children.length > 0}
-
- {/if}
-
- {/each}
-