(null);
+
+// ── Accessors ───────────────────────────────────────────────
+
+export const dragState = {
+ get activeDrag() {
+ return activeDrag;
+ },
+ get pointerX() {
+ return pointerX;
+ },
+ get pointerY() {
+ return pointerY;
+ },
+ get hoveredTargetId() {
+ return hoveredTargetId;
+ },
+ get isDragging() {
+ return activeDrag !== null;
+ },
+ get svelteActionDragActive() {
+ return svelteActionDragActive;
+ },
+ get svelteActionPayload() {
+ return svelteActionPayload;
+ },
+ /** True if ANY drag is happening (Layer 1 or Layer 2). */
+ get anyDragActive() {
+ return activeDrag !== null || svelteActionDragActive;
+ },
+};
+
+// ── Mutations ───────────────────────────────────────────────
+
+export function startDrag(payload: DragPayload) {
+ activeDrag = payload;
+}
+
+export function updatePointer(x: number, y: number) {
+ pointerX = x;
+ pointerY = y;
+}
+
+export function setHoveredTarget(id: string | null) {
+ hoveredTargetId = id;
+}
+
+export function endDrag() {
+ activeDrag = null;
+ hoveredTargetId = null;
+}
+
+/**
+ * Called by app code to inform the passive layer that a svelte-dnd-action
+ * drag has started, along with the payload of the dragged item.
+ *
+ * Usage in TaskList:
+ * onconsider={(e) => {
+ * registerSvelteActionDrag({ type: 'task', data: { id: e.detail.info.id } });
+ * }}
+ * onfinalize={(e) => {
+ * clearSvelteActionDrag();
+ * }}
+ */
+export function registerSvelteActionDrag(payload: DragPayload) {
+ svelteActionDragActive = true;
+ svelteActionPayload = payload;
+}
+
+export function clearSvelteActionDrag() {
+ svelteActionDragActive = false;
+ svelteActionPayload = null;
+ hoveredTargetId = null;
+}
+
+/**
+ * Check whether a given drag type is currently active (either layer).
+ */
+export function isTypeBeingDragged(type: DragType): boolean {
+ if (activeDrag?.type === type) return true;
+ if (svelteActionPayload?.type === type) return true;
+ return false;
+}
diff --git a/packages/shared-ui/src/dnd/drop-target.ts b/packages/shared-ui/src/dnd/drop-target.ts
new file mode 100644
index 000000000..5a4494aae
--- /dev/null
+++ b/packages/shared-ui/src/dnd/drop-target.ts
@@ -0,0 +1,107 @@
+/**
+ * Svelte action: use:dropTarget
+ *
+ * Registers an element as a drop target for Layer 1 (pointer-events) drags.
+ * Handles hover detection via the global drag state and listens for
+ * the 'mana-drag-drop' custom event fired by dragSource.
+ *
+ * Usage:
+ * assignTag(task.id, payload.data.id),
+ * canDrop: (p) => !task.labelIds.includes(p.data.id),
+ * }}>
+ */
+
+import type { DropTargetOptions, DragPayload } from './types';
+import { dragState, setHoveredTarget } from './drag-state.svelte';
+
+let targetCounter = 0;
+
+export function dropTarget(node: HTMLElement, options: DropTargetOptions) {
+ let opts = options;
+ const targetId = `drop-target-${++targetCounter}`;
+
+ node.dataset.manaDropTarget = targetId;
+
+ let isHovering = false;
+
+ function accepts(payload: DragPayload | null): boolean {
+ if (!payload || opts.disabled) return false;
+ if (!opts.accepts.includes(payload.type)) return false;
+ if (opts.canDrop && !opts.canDrop(payload)) return false;
+ return true;
+ }
+
+ function handlePointerMove(e: PointerEvent) {
+ if (!dragState.isDragging) return;
+
+ const payload = dragState.activeDrag;
+ if (!payload || !accepts(payload)) return;
+
+ const rect = node.getBoundingClientRect();
+ const inside =
+ e.clientX >= rect.left &&
+ e.clientX <= rect.right &&
+ e.clientY >= rect.top &&
+ e.clientY <= rect.bottom;
+
+ if (inside && !isHovering) {
+ isHovering = true;
+ node.classList.add('mana-drop-target-hover');
+ setHoveredTarget(targetId);
+ opts.onHover?.(payload);
+ } else if (!inside && isHovering) {
+ isHovering = false;
+ node.classList.remove('mana-drop-target-hover');
+ setHoveredTarget(null);
+ opts.onLeave?.();
+ }
+ }
+
+ function handleDrop(_e: CustomEvent<{ x: number; y: number }>) {
+ if (!isHovering) return;
+
+ const payload = dragState.activeDrag;
+ if (!payload || !accepts(payload)) {
+ resetHover();
+ return;
+ }
+
+ opts.onDrop(payload);
+ resetHover();
+
+ // Brief success flash
+ node.classList.add('mana-drop-target-success');
+ setTimeout(() => node.classList.remove('mana-drop-target-success'), 400);
+ }
+
+ function resetHover() {
+ isHovering = false;
+ node.classList.remove('mana-drop-target-hover');
+ setHoveredTarget(null);
+ opts.onLeave?.();
+ }
+
+ // Also reset when drag ends without drop on this target
+ function handleDragEnd() {
+ if (isHovering) resetHover();
+ }
+
+ document.addEventListener('pointermove', handlePointerMove);
+ document.addEventListener('mana-drag-drop', handleDrop as EventListener);
+ // dragSource fires pointerup → endDrag, but in case of cancel:
+ document.addEventListener('pointercancel', handleDragEnd);
+
+ return {
+ update(newOptions: DropTargetOptions) {
+ opts = newOptions;
+ },
+ destroy() {
+ resetHover();
+ document.removeEventListener('pointermove', handlePointerMove);
+ document.removeEventListener('mana-drag-drop', handleDrop as EventListener);
+ document.removeEventListener('pointercancel', handleDragEnd);
+ },
+ };
+}
diff --git a/packages/shared-ui/src/dnd/index.ts b/packages/shared-ui/src/dnd/index.ts
new file mode 100644
index 000000000..882eafc3e
--- /dev/null
+++ b/packages/shared-ui/src/dnd/index.ts
@@ -0,0 +1,32 @@
+// Types
+export type {
+ DragType,
+ DragPayload,
+ TagDragData,
+ TaskDragData,
+ DragSourceOptions,
+ DropTargetOptions,
+ PassiveDropZoneOptions,
+ ActionZoneProps,
+} from './types';
+
+// Actions
+export { dragSource } from './drag-source';
+export { dropTarget } from './drop-target';
+export { passiveDropZone } from './passive-drop';
+
+// State
+export {
+ dragState,
+ startDrag,
+ endDrag,
+ updatePointer,
+ setHoveredTarget,
+ registerSvelteActionDrag,
+ clearSvelteActionDrag,
+ isTypeBeingDragged,
+} from './drag-state.svelte';
+
+// Components
+export { default as DragPreview } from './DragPreview.svelte';
+export { default as ActionZone } from './ActionZone.svelte';
diff --git a/packages/shared-ui/src/dnd/passive-drop.ts b/packages/shared-ui/src/dnd/passive-drop.ts
new file mode 100644
index 000000000..1d58298d3
--- /dev/null
+++ b/packages/shared-ui/src/dnd/passive-drop.ts
@@ -0,0 +1,116 @@
+/**
+ * Svelte action: use:passiveDropZone
+ *
+ * Layer 2: Detects when a svelte-dnd-action drag hovers over this element.
+ *
+ * svelte-dnd-action uses pointer events internally. We listen to global
+ * pointermove and use elementFromPoint to check if the pointer is over
+ * this zone. When the pointer is released over the zone, we fire onDrop.
+ *
+ * The app must call registerSvelteActionDrag(payload) when a
+ * svelte-dnd-action drag starts (in the onconsider handler) and
+ * clearSvelteActionDrag() when it ends (in onfinalize).
+ *
+ * Usage:
+ *