diff --git a/apps/web/src/features/canvas/AppMenu.tsx b/apps/web/src/features/canvas/AppMenu.tsx
new file mode 100644
index 0000000..4721600
--- /dev/null
+++ b/apps/web/src/features/canvas/AppMenu.tsx
@@ -0,0 +1,334 @@
+import { useRef, useState, type ReactNode } from "react";
+import { useNavigate } from "react-router-dom";
+import { GlassPanel, Icon, Popover, useTheme, type IconName } from "@notux/ui";
+import {
+ useAssetStore,
+ useCommandStore,
+ usePageStore,
+ useShapeStore,
+ useToolStore,
+} from "@notux/canvas";
+
+interface MenuItemProps {
+ icon?: IconName;
+ label: string;
+ shortcut?: string;
+ disabled?: boolean;
+ onClick(): void;
+}
+
+function MenuItem({ icon, label, shortcut, disabled, onClick }: MenuItemProps) {
+ return (
+
+ );
+}
+
+function MenuSection({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ );
+}
+
+export function AppMenu() {
+ const navigate = useNavigate();
+ const { theme, toggle: toggleTheme } = useTheme();
+
+ const pages = usePageStore((s) => s.pages);
+ const activePageId = usePageStore((s) => s.activePageId);
+ usePageStore((s) => s.revision); // re-render on remote page changes
+ const setActivePage = usePageStore((s) => s.setActivePage);
+ const addPage = usePageStore((s) => s.addPage);
+ const deletePage = usePageStore((s) => s.deletePage);
+ const renamePage = usePageStore((s) => s.renamePage);
+ const reorderPage = usePageStore((s) => s.reorderPage);
+
+ const canImport = useAssetStore((s) => s.canImport);
+ const canUndo = useCommandStore((s) => s.canUndo);
+ const canRedo = useCommandStore((s) => s.canRedo);
+ const selection = useToolStore((s) => s.selection);
+ useShapeStore((s) => s.revision); // keep Object actions in sync
+
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [pagesOpen, setPagesOpen] = useState(false);
+ const [dragIdx, setDragIdx] = useState(null);
+ const [overIdx, setOverIdx] = useState(null);
+
+ const menuBtnRef = useRef(null);
+ const pagesBtnRef = useRef(null);
+ const fileRef = useRef(null);
+
+ const activeTitle =
+ pages.find((p) => p.id === activePageId)?.title ?? "Untitled";
+ const hasSelection = selection.size > 0;
+
+ function run(fn: () => void) {
+ fn();
+ setMenuOpen(false);
+ }
+
+ // ----- Object / Edit actions on the current selection -------------------
+ function selectedIds(): string[] {
+ return Array.from(useToolStore.getState().selection);
+ }
+ function zOrder(op: "front" | "forward" | "backward" | "back") {
+ const ids = selectedIds();
+ if (ids.length === 0) return;
+ const store = useShapeStore.getState();
+ if (op === "front") store.bringToFront(activePageId, ids);
+ else if (op === "forward") store.bringForward(activePageId, ids);
+ else if (op === "backward") store.sendBackward(activePageId, ids);
+ else store.sendToBack(activePageId, ids);
+ }
+ function toggleLock() {
+ const ids = selectedIds();
+ if (ids.length === 0) return;
+ const store = useShapeStore.getState();
+ const allLocked = ids.every((id) => store.getShape(activePageId, id)?.locked);
+ store.transact(() => ids.forEach((id) => store.setLocked(activePageId, id, !allLocked)));
+ }
+ function deleteSelection() {
+ const ids = selectedIds();
+ if (ids.length === 0) return;
+ const store = useShapeStore.getState();
+ store.transact(() => ids.forEach((id) => store.deleteShape(activePageId, id)));
+ useToolStore.getState().clearSelection();
+ }
+ function selectAll() {
+ const ids = useShapeStore
+ .getState()
+ .listShapes(activePageId)
+ .map((s) => s.id);
+ useToolStore.getState().setTool("select");
+ useToolStore.getState().setSelection(ids);
+ }
+
+ function commitDrop() {
+ if (dragIdx !== null && overIdx !== null && dragIdx !== overIdx) {
+ reorderPage(dragIdx, overIdx);
+ }
+ setDragIdx(null);
+ setOverIdx(null);
+ }
+
+ // Commands (undo/redo/zoom) are registered by CanvasStage after mount, so
+ // fetch them fresh at click time rather than capturing a stale snapshot.
+ const cmd = () => useCommandStore.getState();
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* Main menu dropdown */}
+ setMenuOpen(false)}
+ anchorRef={menuBtnRef}
+ placement="bottom"
+ className="menu-popover"
+ >
+
+
+
+
+ {/* Pages list popover */}
+ setPagesOpen(false)}
+ anchorRef={pagesBtnRef}
+ placement="bottom"
+ >
+
+
+ Pages
+
+
+ {pages.map((p, i) => (
+
setDragIdx(i)}
+ onDragOver={(e) => {
+ e.preventDefault();
+ setOverIdx(i);
+ }}
+ onDrop={commitDrop}
+ onDragEnd={() => {
+ setDragIdx(null);
+ setOverIdx(null);
+ }}
+ onClick={() => setActivePage(p.id)}
+ >
+ {i + 1}
+ e.stopPropagation()}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") (e.target as HTMLInputElement).blur();
+ }}
+ onBlur={(e) => {
+ const v = e.target.value.trim();
+ if (v && v !== p.title) renamePage(p.id, v);
+ }}
+ aria-label={`Rename ${p.title}`}
+ />
+
+
+ ))}
+
+
+
+ {
+ const files = e.target.files;
+ if (files && files.length > 0) {
+ void useAssetStore.getState().importAtCenter(files);
+ }
+ e.target.value = "";
+ }}
+ />
+ >
+ );
+}
diff --git a/apps/web/src/features/canvas/ColorPicker.tsx b/apps/web/src/features/canvas/ColorPicker.tsx
index bae196c..299a241 100644
--- a/apps/web/src/features/canvas/ColorPicker.tsx
+++ b/apps/web/src/features/canvas/ColorPicker.tsx
@@ -1,5 +1,5 @@
import { useMemo, useState, type RefObject } from "react";
-import { Segmented, Sheet, Slider, Swatch } from "@notux/ui";
+import { Icon, Segmented, Sheet, Slider, Swatch } from "@notux/ui";
import { useDockStore } from "@notux/canvas";
import { useSavedSwatches } from "./useSavedSwatches";
@@ -106,7 +106,7 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) {
aria-label="Pick color from screen"
title="Pick color from screen"
>
- ⊙
+
) : (
@@ -118,7 +118,7 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) {
onClick={onClose}
aria-label="Close color picker"
>
- ✕
+
@@ -219,7 +219,7 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) {
aria-label="Save current color"
title="Save current color"
>
- +
+
diff --git a/apps/web/src/features/canvas/Dock.tsx b/apps/web/src/features/canvas/Dock.tsx
index d62b151..fb8fe2f 100644
--- a/apps/web/src/features/canvas/Dock.tsx
+++ b/apps/web/src/features/canvas/Dock.tsx
@@ -1,35 +1,67 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, type CSSProperties, type Ref } from "react";
import {
- GlassButton,
GlassPanel,
- Instrument,
+ Icon,
Popover,
+ Segmented,
Slider,
Swatch,
+ type IconName,
} from "@notux/ui";
import type { ToolKind } from "@notux/types";
import {
- INSTRUMENT_IDS,
- INSTRUMENT_MAP,
+ PEN_STYLES,
WIDTH_PRESETS,
- useAssetStore,
useDockStore,
useToolStore,
+ type InstrumentId,
} from "@notux/canvas";
import { ColorPicker } from "./ColorPicker";
-import { ThemeToggle } from "./ThemeToggle";
-const DRAWING_TOOLS: ReadonlySet = new Set([
- "pen",
- "highlighter",
- "eraser",
+// Tools that count as "a shape" for the Shapes button's active state.
+const SHAPE_TOOLS: ReadonlySet = new Set([
+ "rect",
+ "ellipse",
+ "polygon",
+ "line",
+ "arrow",
]);
-// A wavy stroke sample for the width-preset buttons (Frame 2).
+const PEN_STYLE_LABELS: Record = {
+ pen: "Pen",
+ fineliner: "Fine",
+ pencil: "Pencil",
+ marker: "Marker",
+ highlighter: "Highlighter",
+ eraser: "Eraser",
+};
+
+const STICKY_COLORS = ["#ffe066", "#ff9eb1", "#9ad0ff", "#b9f6c5", "#d8b4fe"];
+
+interface ShapeItem {
+ tool: ToolKind;
+ variant?: string;
+ icon: IconName;
+ label: string;
+}
+
+const SHAPE_ITEMS: ShapeItem[] = [
+ { tool: "rect", icon: "square", label: "Rectangle" },
+ { tool: "rect", variant: "rounded", icon: "rounded", label: "Rounded rectangle" },
+ { tool: "ellipse", icon: "circle", label: "Ellipse" },
+ { tool: "polygon", variant: "diamond", icon: "diamond", label: "Diamond" },
+ { tool: "polygon", variant: "triangle", icon: "triangle", label: "Triangle" },
+ { tool: "line", icon: "line", label: "Line" },
+ { tool: "arrow", variant: "straight", icon: "arrow", label: "Arrow" },
+ { tool: "arrow", variant: "curved", icon: "arrow-curved", label: "Curved arrow" },
+ { tool: "arrow", variant: "elbow", icon: "arrow-elbow", label: "Elbow arrow" },
+];
+
+// A wavy stroke sample for the width-preset buttons.
function WidthSquiggle({ width }: { width: number }) {
const sw = Math.max(1.5, Math.min(11, width * 0.55));
return (
-