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}