From 1c12d407125b3e6da8967a85ebd6c892a17ea298 Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 21 Jul 2026 16:03:57 +0200 Subject: [PATCH] feat(web): adopt shadcn context-menu for mascot + desktop right-click menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ). - 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 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 , 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. --- VERSION | 2 +- web/package-lock.json | 11 +- web/package.json | 3 +- .../components/desktop-shell/Desktop.svelte | 138 +++++---------- .../context-menu-checkbox-item.svelte | 41 +++++ .../context-menu/context-menu-content.svelte | 28 ++++ .../context-menu-group-heading.svelte | 21 +++ .../ui/context-menu/context-menu-group.svelte | 7 + .../ui/context-menu/context-menu-item.svelte | 27 +++ .../ui/context-menu/context-menu-label.svelte | 24 +++ .../context-menu/context-menu-portal.svelte | 7 + .../context-menu-radio-group.svelte | 16 ++ .../context-menu-radio-item.svelte | 35 ++++ .../context-menu-separator.svelte | 17 ++ .../context-menu/context-menu-shortcut.svelte | 20 +++ .../context-menu-sub-content.svelte | 17 ++ .../context-menu-sub-trigger.svelte | 29 ++++ .../ui/context-menu/context-menu-sub.svelte | 7 + .../context-menu/context-menu-trigger.svelte | 17 ++ .../ui/context-menu/context-menu.svelte | 7 + .../lib/components/ui/context-menu/index.ts | 52 ++++++ web/src/lib/mascot/Mascot.svelte | 9 - web/src/lib/mascot/MascotLayer.svelte | 60 ++++--- web/src/lib/mascot/MascotMenu.svelte | 53 ++++++ web/src/lib/mascot/RadialMenu.svelte | 157 ------------------ 25 files changed, 508 insertions(+), 297 deletions(-) create mode 100644 web/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-content.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-group-heading.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-group.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-item.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-label.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-portal.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-radio-group.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-radio-item.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-separator.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-shortcut.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-sub-content.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-sub.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu-trigger.svelte create mode 100644 web/src/lib/components/ui/context-menu/context-menu.svelte create mode 100644 web/src/lib/components/ui/context-menu/index.ts create mode 100644 web/src/lib/mascot/MascotMenu.svelte delete mode 100644 web/src/lib/mascot/RadialMenu.svelte diff --git a/VERSION b/VERSION index ac454c6..54d1a4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.12.0 +0.13.0 diff --git a/web/package-lock.json b/web/package-lock.json index a08cd35..4bb29d3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,7 +18,8 @@ "uplot": "^1.6.32" }, "devDependencies": { - "@lucide/svelte": "^1.23.0", + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.25.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.3.2", "@tsconfig/svelte": "^5.0.0", @@ -870,7 +871,6 @@ "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -921,9 +921,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.23.0.tgz", - "integrity": "sha512-3LQbKXx9vId6Nx4E2Nu2qwgJfdmr5+CVeVJbxe5cy+HcnCRd9QVVtZXqvgBYAV1OJrPmQAf9/3gJWLCpASC/Ng==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz", + "integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==", "dev": true, "license": "ISC", "peerDependencies": { @@ -1378,7 +1378,6 @@ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } diff --git a/web/package.json b/web/package.json index 58573f6..ebaf7fa 100644 --- a/web/package.json +++ b/web/package.json @@ -17,7 +17,8 @@ "test:watch": "vitest" }, "devDependencies": { - "@lucide/svelte": "^1.23.0", + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.25.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.3.2", "@tsconfig/svelte": "^5.0.0", diff --git a/web/src/lib/components/desktop-shell/Desktop.svelte b/web/src/lib/components/desktop-shell/Desktop.svelte index 2226bd6..e4da1cd 100644 --- a/web/src/lib/components/desktop-shell/Desktop.svelte +++ b/web/src/lib/components/desktop-shell/Desktop.svelte @@ -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 @@ } - +
-