feat(web): adopt shadcn context-menu for mascot + desktop right-click menus
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 mascot's right-click menu was non-interactive — RadialMenu's
root div rendered inside MascotLayer's pointer-events-none root (and the
new DockedLayer wrapper compounded it) without re-enabling pointer-events,
so clicks passed straight through. The desktop right-click menu was a
hand-rolled positioned div, inconsistent with the rest of the UI.

Change: both menus now use the shadcn-svelte context-menu primitive
(bits-ui, portaled to <body>).
- Mascot: MascotMenu.svelte renders the action tree recursively —
  children become ContextMenu.Sub (native hover sub-menu navigation,
  replacing the manual breadcrumb stack), leaves become ContextMenu.Item
  with onSelect. MascotLayer wraps <Mascot> in a ContextMenu.Trigger;
  visibility predicates read reactively off ctx.model so items
  appear/disappear live. Removed the manual menuPos/openMenu/closeMenu
  machinery. RadialMenu.svelte deleted.
- Desktop: the surface's bare-desktop hit area is now a
  ContextMenu.Trigger layer (absolute inset-0, pointer-events-auto)
  placed before the icons/windows in the DOM. The DOM-structure gate
  (icons/windows are pointer-events-auto siblings that paint on top and
  intercept their own right-clicks; bare desktop falls through to the
  trigger) replaces the old fragile e.currentTarget === e.target check.
  Left-click blur moved onto the trigger; Undo/Redo disabled state
  snapshotted via onOpenChange (canUndo/canRedo are wmkit methods).

Risk: the blocker that made the mascot menu non-interactive in the first
place — Mascot.svelte's handleContextMenu called e.stopPropagation(),
which would have prevented a ContextMenu.Trigger wrapper from ever
seeing the right-click. Removed that handler; bits-ui now owns
right-click on the mascot, left-click drag/pet passes through. The
context-menu content portals to <body>, escaping the pointer-events-none
mascot and docked layers entirely — the structural fix, not just a
component swap.

Verification: vitest 38/38; svelte-check + tsc clean for changed files;
eslint clean (the shadcn-generated ui/context-menu/* files carry the
same baseline custom_element_props_identifier warnings as the rest of
the ui/ folder, not from this change); vite build green; runtime
confirmed — right-click mascot opens the action tree with hover
sub-menus, right-click bare desktop opens Cascade/Tile/Show/Reset/
Undo/Redo, right-click on an icon or window does not.
This commit is contained in:
2026-07-21 16:03:57 +02:00
parent 482c7f3448
commit 1c12d40712
25 changed files with 508 additions and 297 deletions

View File

@@ -15,6 +15,7 @@
import WindowLayer from './WindowLayer.svelte'
import DockedLayer from './DockedLayer.svelte'
import Taskbar from './Taskbar.svelte'
import * as ContextMenu from '$lib/components/ui/context-menu'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
@@ -22,37 +23,18 @@
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)
// canUndo/canRedo are plain wmkit method calls (not stores), so they're
// snapshotted once when the menu opens (onOpenChange) rather than read
// reactively in the template. bits-ui auto-dismisses on item select and
// on Escape / click-away, so the old manual menuPos/closeMenu/runMenuAction
// machinery is gone.
let menuCanUndo = $state(false)
let menuCanRedo = $state(false)
function onSurfaceContextMenu(e: MouseEvent) {
if (e.currentTarget !== e.target) return
e.preventDefault()
function onOpenChange(open: boolean) {
if (!open) return
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,
@@ -61,10 +43,6 @@
// 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
@@ -75,17 +53,48 @@
}
</script>
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
<svelte:window onkeydown={onWindowKeydown} />
<div class="fixed inset-0 flex flex-col">
<div
class="relative min-h-0 flex-1 overflow-hidden"
role="presentation"
onclick={onSurfaceClick}
oncontextmenu={onSurfaceContextMenu}
>
<div class="relative min-h-0 flex-1 overflow-hidden" role="presentation">
<GraphBackground />
<ContextMenu.Root {onOpenChange}>
<!-- The bare-desktop hit area. Placed before the icons/windows layers
so they (pointer-events-auto, later in DOM → paint on top) catch
their own right-clicks — the trigger only sees right-clicks that
fall through to bare desktop. This DOM-structure gate replaces the
old `currentTarget === target` event check. Left-click on bare
desktop blurs the focused window (the familiar "click empty
desktop to deselect" affordance). -->
<ContextMenu.Trigger
class="absolute inset-0 z-0"
onclick={() => wm.blur()}
></ContextMenu.Trigger>
<ContextMenu.Content class="min-w-48">
<ContextMenu.Item onSelect={() => wm.arrange('cascade')}>
<LayersIcon class="size-4" /> Cascade windows
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => wm.arrange('tile')}>
<Rows3Icon class="size-4" /> Tile windows
</ContextMenu.Item>
<ContextMenu.Item onSelect={toggleShowDesktop}>
<MonitorIcon class="size-4" /> Show desktop
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={resetIconLayout}>
<RotateCcwIcon class="size-4" /> Reset icon layout
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item disabled={!menuCanUndo} onSelect={() => wm.undo()}>
<Undo2Icon class="size-4" /> Undo
</ContextMenu.Item>
<ContextMenu.Item disabled={!menuCanRedo} onSelect={() => wm.redo()}>
<Redo2Icon class="size-4" /> Redo
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
<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 }}
@@ -107,58 +116,3 @@
<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}

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
import CheckIcon from '@lucide/svelte/icons/check';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
inset,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
inset?: boolean;
children?: Snippet;
} = $props();
</script>
<ContextMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="context-menu-checkbox-item"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute right-2 pointer-events-none">
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</ContextMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import ContextMenuPortal from "./context-menu-portal.svelte";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
portalProps,
class: className,
...restProps
}: ContextMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof ContextMenuPortal>>;
} = $props();
</script>
<ContextMenuPortal {...portalProps}>
<ContextMenuPrimitive.Content
bind:ref
data-slot="context-menu-content"
class={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-md p-1 shadow-md ring-1 duration-100 z-50 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...restProps}
/>
</ContextMenuPortal>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.GroupHeading
bind:ref
data-slot="context-menu-group-heading"
data-inset={inset}
class={cn("text-foreground px-2 py-1.5 text-sm font-medium data-inset:ps-8", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.GroupProps = $props();
</script>
<ContextMenuPrimitive.Group bind:ref data-slot="context-menu-group" {...restProps} />

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: ContextMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<ContextMenuPrimitive.Item
bind:ref
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="context-menu-label"
data-inset={inset}
class={cn("text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8 data-inset:pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ...restProps }: ContextMenuPrimitive.PortalProps = $props();
</script>
<ContextMenuPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(""),
...restProps
}: ContextMenuPrimitive.RadioGroupProps = $props();
</script>
<ContextMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="context-menu-radio-group"
{...restProps}
/>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import CheckIcon from '@lucide/svelte/icons/check';
let {
ref = $bindable(null),
class: className,
inset,
children: childrenProp,
...restProps
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.RadioItem
bind:ref
data-slot="context-menu-radio-item"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute right-2 pointer-events-none">
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</ContextMenuPrimitive.RadioItem>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SeparatorProps = $props();
</script>
<ContextMenuPrimitive.Separator
bind:ref
data-slot="context-menu-separator"
class={cn("bg-border -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="context-menu-shortcut"
class={cn("text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SubContentProps = $props();
</script>
<ContextMenuPrimitive.SubContent
bind:ref
data-slot="context-menu-sub-content"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-32 rounded-md border p-1 shadow-lg duration-100", className)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.SubTrigger
bind:ref
data-slot="context-menu-sub-trigger"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-inset:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto" />
</ContextMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.SubProps = $props();
</script>
<ContextMenuPrimitive.Sub bind:open {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.TriggerProps = $props();
</script>
<ContextMenuPrimitive.Trigger
bind:ref
data-slot="context-menu-trigger"
class={cn("cn-context-menu-trigger select-none", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.RootProps = $props();
</script>
<ContextMenuPrimitive.Root bind:open {...restProps} />

View File

@@ -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,
};

View File

@@ -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'}
></canvas>

View File

@@ -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 @@
</script>
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
<Mascot
bind:runtime
onContextMenu={openMenu}
onPet={onPet}
onRequestName={() => {
nameDialogMode = 'hatch'
nameDialogOpen = true
}}
/>
<ContextMenu.Root>
<!-- The trigger wraps the mascot sprite; right-click on the canvas
bubbles up to it and bits-ui opens the menu content (portaled to
<body>, so it's fully interactive despite this layer's
pointer-events-none root — the old RadialMenu was non-interactive
because it rendered inside this root without re-enabling
pointer-events). Left-click drag/pet passes through untouched. -->
<ContextMenu.Trigger>
<Mascot
bind:runtime
onPet={onPet}
onRequestName={() => {
nameDialogMode = 'hatch'
nameDialogOpen = true
}}
/>
</ContextMenu.Trigger>
<ContextMenu.Content class="min-w-56 max-w-72">
<MascotMenu actions={MASCOT_ACTIONS} ctx={actionCtx} />
</ContextMenu.Content>
</ContextMenu.Root>
</div>
{#if menuPos}
<RadialMenu
pos={menuPos}
ctx={actionCtx}
onDismiss={closeMenu}
/>
{/if}
{#if nameDialogOpen}
<NameDialog
mode={nameDialogMode}

View File

@@ -0,0 +1,53 @@
<script lang="ts">
// 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 <body> 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)))
</script>
{#each visible as a (a.id)}
{#if a.children && a.children.length > 0}
<ContextMenu.Sub>
<ContextMenu.SubTrigger>
{#if a.icon}<a.icon class="size-4" />{/if}
{a.label}
</ContextMenu.SubTrigger>
<ContextMenu.SubContent class="min-w-56 max-w-72">
<MascotMenu actions={a.children} {ctx} />
</ContextMenu.SubContent>
</ContextMenu.Sub>
{:else}
<ContextMenu.Item
onSelect={() => {
a.action?.(ctx)
}}
>
{#if a.icon}<a.icon class="size-4" />{/if}
<span class="flex min-w-0 flex-1 flex-col">
<span class="truncate">{a.label}</span>
{#if a.description}
<span class="text-[11px] leading-snug font-normal text-muted-foreground">{a.description}</span>
{/if}
</span>
</ContextMenu.Item>
{/if}
{/each}

View File

@@ -1,157 +0,0 @@
<script lang="ts" module>
// RadialMenu (now a rounded-button column menu): opened on right-click
// over the mascot. Renders the MASCOT_ACTIONS tree as a stack of
// rounded buttons with full text labels; selecting a node with
// `children` swaps the column to those children + a "Back" button at
// the top (tracked via a local breadcrumb stack). Leaf nodes call
// `action(ctx)` and dismiss.
//
// z-[60] — must beat the desktop's own right-click menu (z-50) and
// sit above the mascot layer (z-[45]). Dismissal mirrors the desktop
// menu: a <svelte:window onclick> closes it, Escape pops one level
// then closes on the next press, and the menu's own clicks
// stopPropagation so they don't bubble to the close handler.
import { MASCOT_ACTIONS } from './actions'
import type { MascotActionCtx, RadialAction } from './types'
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
</script>
<script lang="ts">
let {
pos,
ctx,
onDismiss
}: {
pos: { x: number; y: number }
ctx: MascotActionCtx
onDismiss: () => void
} = $props()
// Breadcrumb stack: each entry is the list of actions shown at that
// level. The top of the stack is the current column.
let stack = $state<RadialAction[][]>([MASCOT_ACTIONS])
let depth = $derived(stack.length)
let current = $derived(stack[depth - 1] ?? [])
// Clamp the anchor to the viewport, then decide which side to grow
// toward based on anchor position alone (no measure-then-flip — that
// paints off-screen first). When the anchor is in the bottom half,
// the menu grows upward (bottom edge aligns with anchor.y); same for
// the right edge when in the right half. The chicken lives on the
// desktop surface's bottom edge, so this almost always flips up.
const anchor = $derived.by(() => {
const margin = 8
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
const vh = typeof window !== 'undefined' ? window.innerHeight : 768
const x = Math.max(margin, Math.min(vw - margin, pos.x))
const y = Math.max(margin, Math.min(vh - margin, pos.y))
return {
x,
y,
growUp: y > vh / 2,
growLeft: x > vw / 2
}
})
let host = $state<HTMLDivElement | null>(null)
const visibleItems = $derived(current.filter((a) => !a.visible || a.visible(ctx.model)))
function selectItem(a: RadialAction, ev: MouseEvent) {
ev.stopPropagation()
if (a.children && a.children.length > 0) {
stack = [...stack, a.children]
return
}
if (a.action) {
a.action(ctx)
}
onDismiss()
}
function back(ev: MouseEvent) {
ev.stopPropagation()
if (stack.length > 1) {
stack = stack.slice(0, -1)
} else {
onDismiss()
}
}
function onWindowClick() {
onDismiss()
}
function onWindowKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
if (stack.length > 1) {
stack = stack.slice(0, -1)
} else {
onDismiss()
}
}
}
// Reset the stack when the menu is (re)opened with a new pos.
$effect(() => {
void pos
stack = [MASCOT_ACTIONS]
})
</script>
<svelte:window onclick={onWindowClick} onkeydown={onWindowKeydown} />
<div
bind:this={host}
class="fixed z-[60] min-w-56 max-w-72 rounded-xl border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
style="left: {anchor.growLeft ? 'auto' : `${anchor.x}px`}; right: {anchor.growLeft ? `${window.innerWidth - anchor.x}px` : 'auto'}; top: {anchor.growUp ? 'auto' : `${anchor.y}px`}; bottom: {anchor.growUp ? `${window.innerHeight - anchor.y}px` : 'auto'};"
role="menu"
tabindex="-1"
aria-label="Mascot actions"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
if (stack.length > 1) stack = stack.slice(0, -1)
else onDismiss()
}
}}
>
{#if depth > 1}
<button
type="button"
class="mb-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-popover-foreground/70 hover:bg-accent hover:text-accent-foreground"
onclick={back}
>
<ChevronLeftIcon class="size-4" /> Back
</button>
<div class="my-1 h-px bg-border"></div>
{/if}
{#each visibleItems as a (a.id)}
<button
type="button"
class="flex w-full items-start justify-between gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={(ev) => selectItem(a, ev)}
oncontextmenu={(ev) => ev.preventDefault()}
>
<span class="flex min-w-0 flex-1 items-start gap-2">
{#if a.icon}
<a.icon class="mt-0.5 size-4 shrink-0" />
{/if}
<span class="flex min-w-0 flex-col">
<span class="truncate">{a.label}</span>
{#if a.description}
<span class="text-[11px] leading-snug font-normal text-popover-foreground/60">{a.description}</span>
{/if}
</span>
</span>
{#if a.children && a.children.length > 0}
<ChevronRightIcon class="mt-0.5 size-4 shrink-0 opacity-60" />
{/if}
</button>
{/each}
</div>