+ Drag views between docks, pin or hide them — then Done. Everything is undoable, and ⌘K
+ reaches every move as a command.
+
+
+
+
+
+
+ {DOCK_REGIONS.map((region) => (
+
+ ))}
+
+
+
+ {nudge && (
+
+ Keep this arrangement?
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/workbench/PanelViewHost.tsx b/apps/web/src/workbench/PanelViewHost.tsx
index bfb656acf..2e8f00a25 100644
--- a/apps/web/src/workbench/PanelViewHost.tsx
+++ b/apps/web/src/workbench/PanelViewHost.tsx
@@ -8,10 +8,19 @@
* dispatching the same `moveSlot` store action the palette commands run.
*/
import type { ComponentType } from 'react'
-import { PopoverContent, PopoverRoot, PopoverTrigger } from '@xnetjs/ui'
-import { ArrowLeftRight, X } from 'lucide-react'
-import { useState } from 'react'
+import { getCommandRegistry } from '@xnetjs/plugins'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuPortal,
+ DropdownMenuPositioner,
+ DropdownMenuTrigger
+} from '@xnetjs/ui'
+import { ArrowLeftRight, GripVertical, X } from 'lucide-react'
+import { isLayoutTreeEnabled } from './experiments'
import { regionOf } from './layout-tree'
+import { beginSlotDrag } from './slot-drag'
import {
getSlotView,
movableRegionsFor,
@@ -57,9 +66,13 @@ export function getPanelViews(slot: 'left' | 'bottom' | 'right'): PanelViewDefin
}))
}
-/** The Move menu — the pointer/touch twin of the `slot.move:*` commands. */
+/**
+ * The Move menu — the pointer/touch twin of the `slot.move:*` commands.
+ * A DropdownMenu, not a Popover: menu item selection commits before the
+ * menu dismisses, which kills the click-vs-outside-dismissal race the
+ * corner dock hit during 0280 validation (0282 phase 2).
+ */
export function MoveViewMenu({ viewId }: { viewId: string }) {
- const [open, setOpen] = useState(false)
const view = getSlotView(viewId)
const moveSlot = useWorkbench((state) => state.moveSlot)
const currentRegion = useWorkbench((state) => regionOf(state.tree, viewId))
@@ -67,34 +80,36 @@ export function MoveViewMenu({ viewId }: { viewId: string }) {
const targets = movableRegionsFor(view).filter(({ region }) => region !== currentRegion)
if (targets.length === 0) return null
return (
-
-
-
-
-
- {targets.map(({ region, label }) => (
+
+ {
- setOpen(false)
- moveSlot(viewId, region)
- }}
- className="flex w-full cursor-pointer items-center border-none bg-transparent px-2 py-1.5 text-left text-xs text-ink-2 transition-colors hover:bg-surface-2 hover:text-ink-1"
+ title="Move view"
+ aria-label="Move view"
+ className="flex cursor-pointer items-center border-none bg-transparent p-0.5 text-ink-3 hover:text-ink-1"
>
- Move to {label}
+
- ))}
-
-
+ }
+ />
+
+
+
+ {targets.map(({ region, label }) => (
+ moveSlot(viewId, region)}>
+ Move to {label}
+
+ ))}
+ void getCommandRegistry().runCommand('workspace.customize')}
+ >
+ Customize layout…
+
+
+
+
+
)
}
@@ -116,17 +131,44 @@ export function PanelViewHost({ slot }: { slot: 'left' | 'bottom' }) {
}
const View = view.component
+ // Drag is a malleable-shell affordance; the legacy renderer only
+ // partially reflects tree moves, so the source is flag-gated (0282).
+ const draggable = isLayoutTreeEnabled()
return (
-
+ {
+ if (!draggable) return
event.dataTransfer.setData(SLOT_DRAG_TYPE, view.id)
event.dataTransfer.effectAllowed = 'move'
+ // The ghost is the header itself — the thing being moved.
+ event.dataTransfer.setDragImage(event.currentTarget, 12, 12)
+ event.currentTarget.classList.add('opacity-40')
+ beginSlotDrag(view.id)
+ }}
+ onDragEnd={(event) => {
+ event.currentTarget.classList.remove('opacity-40')
}}
>
+ {draggable && (
+
+ )}
diff --git a/apps/web/src/workbench/ShellFrame.tsx b/apps/web/src/workbench/ShellFrame.tsx
index 74e4f2411..79c5e701b 100644
--- a/apps/web/src/workbench/ShellFrame.tsx
+++ b/apps/web/src/workbench/ShellFrame.tsx
@@ -11,12 +11,13 @@
* Behind `xnet:experiment:layout-tree` (see experiments.ts); the legacy
* shells render by default until parity is proven.
*/
-import type { DragEvent, ReactNode } from 'react'
import { DemoBanner, useDemoMode } from '@xnetjs/react'
+import { useState, type DragEvent, type ReactNode } from 'react'
import { Group, Panel, useDefaultLayout } from 'react-resizable-panels'
import { GlobalSearch } from '../components/GlobalSearch'
import { UndoToastProvider } from '../components/UndoToast'
import { WorkspaceCommands } from '../components/WorkspaceCommands'
+import { ArrangeOverlay } from './ArrangeOverlay'
import { CalmSurface } from './calm/CalmSurface'
import { QuietChrome } from './calm/QuietChrome'
import { SurfaceDockLauncher } from './calm/SurfaceDock'
@@ -27,7 +28,9 @@ import { useFocusRing } from './focus'
import { Hairline } from './Hairline'
import { slotsIn, type LayoutTree, type RegionId } from './layout-tree'
import { PanelViewHost, SLOT_DRAG_TYPE } from './PanelViewHost'
+import { useSlotDragActive, useSlotLanding } from './slot-drag'
import { getSlotView } from './slot-registry'
+import { regionLabel } from './SlotAnnouncer'
import { useWorkbench, type PanelSide } from './state'
const FRAME =
@@ -59,6 +62,80 @@ function dropProps(region: RegionId) {
}
}
+/**
+ * Dress an OPEN dock as a live drop target while a slot drag is in
+ * flight, plus the 700ms landing flash after any move (0282 phase 2 —
+ * research patterns 3 and 7). Ring via ring utilities (transition, not
+ * animation, so reduced-motion needs no special casing; the flash uses
+ * the same mechanism and simply appears/disappears without motion).
+ */
+function DockDropDress({ region, children }: { region: RegionId; children: ReactNode }) {
+ const dragging = useSlotDragActive() !== null
+ const landed = useSlotLanding(region)
+ const [hovered, setHovered] = useState(0)
+ const ring = dragging
+ ? hovered > 0
+ ? 'rounded-md ring-2 ring-inset ring-accent-ink'
+ : 'rounded-md ring-2 ring-inset ring-accent-ink/40'
+ : landed
+ ? 'rounded-md ring-2 ring-inset ring-accent-ink/70'
+ : ''
+ return (
+
{
+ if (event.dataTransfer.types.includes(SLOT_DRAG_TYPE)) setHovered((n) => n + 1)
+ }}
+ onDragLeave={() => setHovered((n) => Math.max(0, n - 1))}
+ onDropCapture={() => setHovered(0)}
+ >
+ {children}
+
+ )
+}
+
+/**
+ * The edge strip a CLOSED dock materializes as during a slot drag —
+ * without it, "pop this out and slot it in over there" silently no-ops
+ * (0282's one mechanical bug). Dropping opens the dock with the view.
+ */
+function DockEdgeStrip({ region }: { region: RegionId }) {
+ const side = region === 'dock.left' ? 'left-0' : region === 'dock.right' ? 'right-0' : ''
+ const shape =
+ region === 'dock.bottom'
+ ? 'inset-x-16 bottom-0 h-12 rounded-t-lg'
+ : `inset-y-16 ${side} w-12 ${region === 'dock.left' ? 'rounded-r-lg' : 'rounded-l-lg'}`
+ return (
+
{
+ const viewId = event.dataTransfer.getData(SLOT_DRAG_TYPE)
+ if (!viewId) return
+ const state = useWorkbench.getState()
+ state.moveSlot(viewId, region)
+ // Open the dock so the landing (and its flash) is visible.
+ const dockSide =
+ region === 'dock.left' ? 'left' : region === 'dock.right' ? 'right' : 'bottom'
+ state.showPanelView(dockSide, viewId)
+ }}
+ >
+
+ {regionLabel(region)}
+
+
+ )
+}
+
/** The dock body: the active placement's view, bare or hosted. */
function DockBody({ tree, region, side }: { tree: LayoutTree; region: RegionId; side: PanelSide }) {
const activeViewId = useWorkbench((state) => state[side].activeViewId)
@@ -68,17 +145,17 @@ function DockBody({ tree, region, side }: { tree: LayoutTree; region: RegionId;
if (view && BARE_VIEW_IDS.has(view.id)) {
const Bare = view.component
return (
-
+
-
+
)
}
// Panel-view docks (the 0166 host: header, tabs, move menu).
const slot = side === 'right' ? undefined : side
return (
-
+
{slot ? : view ? : null}
-
+
)
}
@@ -132,6 +209,10 @@ function PinnedFrame({ tree, children }: { tree: LayoutTree; children: ReactNode
const bottomOpen = bottomPlaced && bottom.open
const cornerPlaced = slotsIn(tree, 'dock.corner').length > 0
+ // While a slot drag is live, closed docks materialize as edge strips
+ // so every region is a target (0282 phase 2).
+ const dragging = useSlotDragActive() !== null
+
const horizontal = useDefaultLayout({
id: `xnet:frame:h:${tree.workspaceId}`,
panelIds: [...(leftOpen ? ['left'] : []), 'center', ...(rightOpen ? ['right'] : [])]
@@ -195,6 +276,10 @@ function PinnedFrame({ tree, children }: { tree: LayoutTree; children: ReactNode
{/* The corner dock renders wherever the tree places residents —
pinned calm gains the same launcher quiet has (0280). */}
{cornerPlaced && }
+
+ {dragging && !leftOpen && }
+ {dragging && !rightOpen && }
+ {dragging && !bottomOpen && }
)
}
diff --git a/apps/web/src/workbench/SlotAnnouncer.tsx b/apps/web/src/workbench/SlotAnnouncer.tsx
new file mode 100644
index 000000000..133a9d684
--- /dev/null
+++ b/apps/web/src/workbench/SlotAnnouncer.tsx
@@ -0,0 +1,87 @@
+/**
+ * SlotAnnouncer — accessibility + feedback spine for slot moves (0282).
+ *
+ * Subscribes to the workbench store and DIFFS tree placements, so every
+ * road that moves a view — drag, Move menu, ⌘K command, the companion's
+ * agent tools — produces the same three effects with zero coupling:
+ *
+ * 1. an `aria-live=polite` announcement ("Explorer moved to right
+ * dock"), the Atlassian pragmatic-drag-and-drop guidance;
+ * 2. the landing flash on the receiving dock (markSlotLanding);
+ * 3. focus restored to the moved view's header, when it is rendered.
+ *
+ * Mounted once by the Workbench shell entry.
+ */
+import { useEffect, useState, type JSX } from 'react'
+import { REGION_IDS, type LayoutTree, type RegionId } from './layout-tree'
+import { markSlotLanding } from './slot-drag'
+import { getSlotView } from './slot-registry'
+import { useWorkbench } from './state'
+
+const REGION_LABELS: Record = {
+ surface: 'surface',
+ rail: 'rail',
+ status: 'status bar',
+ 'dock.left': 'left dock',
+ 'dock.right': 'right dock',
+ 'dock.bottom': 'bottom dock',
+ 'dock.corner': 'corner dock'
+}
+
+/** viewId → region for every placed view. */
+function placements(tree: LayoutTree): Map {
+ const map = new Map()
+ for (const region of REGION_IDS) {
+ for (const placement of tree.regions[region]) map.set(placement.viewId, region)
+ }
+ return map
+}
+
+export function SlotAnnouncer(): JSX.Element {
+ const [message, setMessage] = useState('')
+
+ useEffect(() => {
+ let prev = placements(useWorkbench.getState().tree)
+ return useWorkbench.subscribe((state) => {
+ const next = placements(state.tree)
+ for (const [viewId, region] of next) {
+ const before = prev.get(viewId)
+ if (before && before !== region) {
+ const label = getSlotView(viewId)?.label ?? viewId
+ setMessage(`${label} moved to ${REGION_LABELS[region]}`)
+ markSlotLanding(region)
+ // Focus the moved view's header once the dock has re-rendered.
+ requestAnimationFrame(() => {
+ document.querySelector(`[data-slot-view="${viewId}"] > header`)?.focus()
+ })
+ }
+ }
+ prev = next
+ })
+ }, [])
+
+ return (
+
+ {message}
+
+ )
+}
+
+/** Test seam: the diff used by the announcer. */
+export function diffPlacements(
+ before: LayoutTree,
+ after: LayoutTree
+): Array<{ viewId: string; region: RegionId }> {
+ const prev = placements(before)
+ const moves: Array<{ viewId: string; region: RegionId }> = []
+ for (const [viewId, region] of placements(after)) {
+ const was = prev.get(viewId)
+ if (was && was !== region) moves.push({ viewId, region })
+ }
+ return moves
+}
+
+/** Exposed for ShellFrame's edge strips. */
+export function regionLabel(region: RegionId): string {
+ return REGION_LABELS[region]
+}
diff --git a/apps/web/src/workbench/Workbench.tsx b/apps/web/src/workbench/Workbench.tsx
index e2d072fde..fd9b626c1 100644
--- a/apps/web/src/workbench/Workbench.tsx
+++ b/apps/web/src/workbench/Workbench.tsx
@@ -27,6 +27,7 @@ import { MobileShell } from './MobileShell'
import { PanelViewHost } from './PanelViewHost'
import { Rail } from './Rail'
import { ShellFrame } from './ShellFrame'
+import { SlotAnnouncer } from './SlotAnnouncer'
import { useWorkbench } from './state'
import { StatusBar } from './StatusBar'
import { useIsCompact } from './use-layout-mode'
@@ -151,6 +152,8 @@ export function Workbench({ children }: { children: ReactNode }) {
)}
{/* Workspace quick switcher + verbs (0280) — commands exist in every shell. */}
+ {/* Slot-move announcements + landing flash + focus (0282). */}
+
{/* First-run coachmarks (0206) — portals to , so position here is moot. */}
{/* Opt-in "time well spent" wind-down (Charter §Calm, 0234); off by default. */}
diff --git a/apps/web/src/workbench/WorkspaceSwitcher.tsx b/apps/web/src/workbench/WorkspaceSwitcher.tsx
index 37ccb9888..f5abb900a 100644
--- a/apps/web/src/workbench/WorkspaceSwitcher.tsx
+++ b/apps/web/src/workbench/WorkspaceSwitcher.tsx
@@ -14,11 +14,12 @@ import { createNodeId, WorkspaceSchema } from '@xnetjs/data'
import { getCommandRegistry } from '@xnetjs/plugins'
import { useMutate, useQuery } from '@xnetjs/react'
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@xnetjs/ui'
-import { Layers, Save } from 'lucide-react'
+import { Layers, Save, SlidersHorizontal } from 'lucide-react'
import { useEffect, useRef, useState, type JSX } from 'react'
import { contributeTips } from '../coachmarks'
import { ShareDialog } from '../components/ShareDialog'
import { AGENT_LAYOUT_EVENT } from '../plugins/workspace-agent-module'
+import { isLayoutTreeEnabled } from './experiments'
import {
isPresetWorkspaceId,
parseWorkspacePayload,
@@ -33,6 +34,20 @@ import { useWorkbench } from './state'
// One first-run tip (0206): the layout is yours to keep — say so at the
// button that proves it. Registered for the home list view; the anchor only
// exists in the pinned calm shell, so nobody else ever sees it.
+// Arrange-mode first-run tip (0282): anchored at the grab handle, which
+// only renders when the malleable shell is on — pinned-legacy users
+// never see it.
+contributeTips([
+ {
+ id: 'home:workspace-grab@1',
+ view: 'home',
+ anchor: '[data-coach="workspace.grab"]',
+ title: 'This panel moves',
+ body: 'Drag it to another dock — or press ⌘K and type “customize” to arrange everything at once.',
+ side: 'right'
+ }
+])
+
contributeTips([
{
id: 'home:workspace-save@1',
@@ -300,6 +315,18 @@ export function WorkspaceSwitcher(): JSX.Element | null {
Save current layout as…
+ {isLayoutTreeEnabled() && (
+ {
+ close()
+ void getCommandRegistry().runCommand('workspace.customize')
+ }}
+ >
+
+ Customize layout…
+
+ )}
>
)}
diff --git a/apps/web/src/workbench/layout-tree.ts b/apps/web/src/workbench/layout-tree.ts
index 2e79c4b83..2e1342c14 100644
--- a/apps/web/src/workbench/layout-tree.ts
+++ b/apps/web/src/workbench/layout-tree.ts
@@ -5,6 +5,7 @@
*/
export {
createPresetTree,
+ insertSlot,
moveSlot,
parseWorkspacePayload,
placementOf,
diff --git a/apps/web/src/workbench/slot-drag.test.tsx b/apps/web/src/workbench/slot-drag.test.tsx
new file mode 100644
index 000000000..cbedcf797
--- /dev/null
+++ b/apps/web/src/workbench/slot-drag.test.tsx
@@ -0,0 +1,83 @@
+/**
+ * Slot-drag lifecycle + announcer diff (0282 phase 2): the drag store
+ * broadcasts begin/end (with the window safety net), the landing flash
+ * expires, and the announcer diff catches moves from ANY road.
+ */
+import { act, render, screen } from '@testing-library/react'
+import React from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { registerBuiltinSlotViews } from './builtin-slot-views'
+import { createPresetTree, moveSlot } from './layout-tree'
+import {
+ activeSlotDrag,
+ beginSlotDrag,
+ endSlotDrag,
+ markSlotLanding,
+ useSlotDragActive,
+ useSlotLanding
+} from './slot-drag'
+import { diffPlacements, SlotAnnouncer } from './SlotAnnouncer'
+import { useWorkbench } from './state'
+
+registerBuiltinSlotViews()
+
+function DragProbe() {
+ const active = useSlotDragActive()
+ const landed = useSlotLanding('dock.right')
+ return
+}
+
+beforeEach(() => {
+ endSlotDrag()
+ useWorkbench.getState().applyPreset('calm')
+})
+
+describe('slot drag lifecycle', () => {
+ it('broadcasts begin/end to subscribers', () => {
+ render()
+ expect(screen.getByTestId('probe').dataset.drag).toBe('none')
+ act(() => beginSlotDrag('tasks'))
+ expect(screen.getByTestId('probe').dataset.drag).toBe('tasks')
+ act(() => endSlotDrag())
+ expect(screen.getByTestId('probe').dataset.drag).toBe('none')
+ })
+
+ it('window dragend is the safety net for unmounted sources', () => {
+ render()
+ act(() => beginSlotDrag('tasks'))
+ act(() => {
+ window.dispatchEvent(new Event('dragend'))
+ })
+ expect(activeSlotDrag()).toBeNull()
+ expect(screen.getByTestId('probe').dataset.drag).toBe('none')
+ })
+
+ it('the landing flash turns itself off after 700ms', () => {
+ vi.useFakeTimers()
+ render()
+ act(() => markSlotLanding('dock.right'))
+ expect(screen.getByTestId('probe').dataset.landed).toBe('true')
+ act(() => {
+ vi.advanceTimersByTime(750)
+ })
+ expect(screen.getByTestId('probe').dataset.landed).toBe('false')
+ vi.useRealTimers()
+ })
+})
+
+describe('announcer', () => {
+ it('diffPlacements reports exactly the moved views', () => {
+ const before = createPresetTree('calm')
+ const after = moveSlot(before, 'context', 'dock.left')
+ expect(diffPlacements(before, after)).toEqual([{ viewId: 'context', region: 'dock.left' }])
+ expect(diffPlacements(before, before)).toEqual([])
+ })
+
+ it('announces store moves from any road with the view label', () => {
+ render()
+ act(() => {
+ useWorkbench.getState().moveSlot('navigator', 'dock.right')
+ })
+ expect(screen.getByRole('status').textContent).toBe('Navigator moved to right dock')
+ })
+})
diff --git a/apps/web/src/workbench/slot-drag.ts b/apps/web/src/workbench/slot-drag.ts
new file mode 100644
index 000000000..2310bd6b4
--- /dev/null
+++ b/apps/web/src/workbench/slot-drag.ts
@@ -0,0 +1,90 @@
+/**
+ * Slot-drag lifecycle (exploration 0282, research pattern 3).
+ *
+ * HTML5 DnD only tells the element under the cursor about a drag; the
+ * shell needs the OPPOSITE — while any slot view is being dragged, every
+ * dock (including closed ones) must materialize as a visible drop target.
+ * This tiny store broadcasts "a slot drag is in flight" to whoever
+ * renders targets, the way `setNodeTransfer` carries node drags.
+ *
+ * The window `dragend`/`drop` listeners are the safety net: `dragend`
+ * fires on the SOURCE element even for cancelled drags, but a source
+ * unmounted mid-drag (its dock re-rendered) would otherwise leave the
+ * shell stuck in drag dress.
+ */
+import { useSyncExternalStore } from 'react'
+
+export interface ActiveSlotDrag {
+ viewId: string
+}
+
+type Listener = () => void
+
+let active: ActiveSlotDrag | null = null
+const listeners = new Set()
+
+function emit(): void {
+ for (const listener of listeners) listener()
+}
+
+export function beginSlotDrag(viewId: string): void {
+ active = { viewId }
+ emit()
+}
+
+export function endSlotDrag(): void {
+ if (!active) return
+ active = null
+ emit()
+}
+
+export function activeSlotDrag(): ActiveSlotDrag | null {
+ return active
+}
+
+if (typeof window !== 'undefined') {
+ window.addEventListener('dragend', endSlotDrag)
+ window.addEventListener('drop', endSlotDrag)
+}
+
+/** The in-flight slot drag, or null. Re-renders on begin/end only. */
+export function useSlotDragActive(): ActiveSlotDrag | null {
+ return useSyncExternalStore(
+ (callback) => {
+ listeners.add(callback)
+ return () => listeners.delete(callback)
+ },
+ activeSlotDrag,
+ () => null
+ )
+}
+
+// ─── Landing flash (research pattern 7) ─────────────────────────────
+
+const FLASH_MS = 700
+
+let landingRegion: string | null = null
+let landingTimer: ReturnType | undefined
+
+/** Record where a drop landed so that dock can flash (motion-safe). */
+export function markSlotLanding(region: string): void {
+ landingRegion = region
+ emit()
+ clearTimeout(landingTimer)
+ landingTimer = setTimeout(() => {
+ landingRegion = null
+ emit()
+ }, FLASH_MS)
+}
+
+/** Whether this region should currently render its landing flash. */
+export function useSlotLanding(region: string): boolean {
+ return useSyncExternalStore(
+ (callback) => {
+ listeners.add(callback)
+ return () => listeners.delete(callback)
+ },
+ () => landingRegion === region,
+ () => false
+ )
+}
diff --git a/apps/web/src/workbench/slot-registry.tsx b/apps/web/src/workbench/slot-registry.tsx
index c0c8ac5cc..7e9abe80a 100644
--- a/apps/web/src/workbench/slot-registry.tsx
+++ b/apps/web/src/workbench/slot-registry.tsx
@@ -76,6 +76,36 @@ function registerMoveCommands(view: SlotContribution): Array<() => void> {
}).dispose
)
}
+ // Within-dock reorder (0282 phase 4) — the keyboard road.
+ for (const [direction, delta] of [
+ ['up', -1],
+ ['down', 1]
+ ] as const) {
+ disposers.push(
+ commands.register({
+ id: `slot.reorder:${view.id}:${direction}`,
+ title: `View: Move ${view.label} ${direction === 'up' ? 'earlier' : 'later'} in its dock`,
+ when: () => {
+ const tree = useWorkbench.getState().tree
+ const region = regionOf(tree, view.id)
+ if (!region) return false
+ const ordered = tree.regions[region]
+ const index = ordered.findIndex((placement) => placement.viewId === view.id)
+ const next = index + delta
+ return index >= 0 && next >= 0 && next < ordered.length
+ },
+ run: () => {
+ const state = useWorkbench.getState()
+ const region = regionOf(state.tree, view.id)
+ if (!region) return
+ const index = state.tree.regions[region].findIndex(
+ (placement) => placement.viewId === view.id
+ )
+ state.insertSlot(view.id, region, index + delta)
+ }
+ }).dispose
+ )
+ }
disposers.push(
commands.register({
id: `slot.hide:${view.id}`,
diff --git a/apps/web/src/workbench/state.ts b/apps/web/src/workbench/state.ts
index dfec2c823..7cbb6c220 100644
--- a/apps/web/src/workbench/state.ts
+++ b/apps/web/src/workbench/state.ts
@@ -15,6 +15,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import {
createPresetTree,
+ insertSlot as insertSlotInTree,
moveSlot as moveSlotInTree,
setSlotTier as setSlotTierInTree,
slotsIn,
@@ -183,6 +184,12 @@ interface WorkbenchState {
chrome: ChromePosture
/** Quiet shell disclosure level (0273). Ephemeral, not persisted. */
discloseLevel: DiscloseLevel
+ /**
+ * Arrange mode (0282): the shell renders as an editable schematic of
+ * its own layout tree. Ephemeral — never persisted (same rule as
+ * `discloseLevel`); reload always lands on the live shell.
+ */
+ arranging: boolean
/**
* Contextual-canvas target (0250). When set, the calm shell's right Canvas
* hosts the full content view for this node (the Claude "artifact opens on
@@ -247,6 +254,8 @@ interface WorkbenchState {
loadWorkspace: (payload: WorkspacePayload) => void
/** Move a view to another region (keeps its tier; ordered last). */
moveSlot: (viewId: string, region: RegionId) => void
+ /** Insert a view at an index within a region (reorder or cross-move). */
+ insertSlot: (viewId: string, region: RegionId, index: number) => void
/** Change a placed view's disclosure tier. */
setSlotTier: (viewId: string, tier: SlotTier) => void
@@ -262,6 +271,8 @@ interface WorkbenchState {
toggleChrome: () => void
/** Update the quiet shell's disclosure level (0273). */
setDiscloseLevel: (level: DiscloseLevel) => void
+ /** Enter/exit arrange mode (0282). */
+ setArranging: (arranging: boolean) => void
/** Open the contextual Canvas hosting a node's full content view. */
openCanvas: (target: { nodeType: TabNodeType; nodeId: string; title?: string }) => void
/** Close the Canvas and clear its content target (back to the inspector). */
@@ -380,6 +391,7 @@ export const useWorkbench = create()(
// away (`View: Quiet chrome`) and flips for new identities post-dogfood.
chrome: 'pinned',
discloseLevel: 0,
+ arranging: false,
canvasTarget: null,
mode: 'default',
zenSnapshot: null,
@@ -417,6 +429,12 @@ export const useWorkbench = create()(
return tree === state.tree ? {} : { tree }
}),
+ insertSlot: (viewId, region, index) =>
+ set((state) => {
+ const tree = insertSlotInTree(state.tree, viewId, region, index)
+ return tree === state.tree ? {} : { tree }
+ }),
+
setSlotTier: (viewId, tier) =>
set((state) => {
const tree = setSlotTierInTree(state.tree, viewId, tier)
@@ -468,6 +486,8 @@ export const useWorkbench = create()(
setDiscloseLevel: (discloseLevel) =>
set((state) => (state.discloseLevel === discloseLevel ? {} : { discloseLevel })),
+ setArranging: (arranging) => set({ arranging }),
+
openCanvas: (target) =>
set((state) => ({ canvasTarget: target, right: { ...state.right, open: true } })),
@@ -798,11 +818,12 @@ export const useWorkbench = create()(
}
return state as WorkbenchState
},
- // The disclosure level is a live interaction state (0273) — persisting
- // it would resurrect a lit/overlaid shell on reload.
+ // Disclosure level and arrange mode are live interaction state
+ // (0273/0282) — persisting them would resurrect a lit/overlaid or
+ // mid-edit shell on reload.
partialize: (state) =>
Object.fromEntries(
- Object.entries(state).filter(([key]) => key !== 'discloseLevel')
+ Object.entries(state).filter(([key]) => key !== 'discloseLevel' && key !== 'arranging')
) as WorkbenchState
}
)
diff --git a/docs/explorations/0282_[x]_WORKSPACE_EDITING_AFFORDANCES_AND_LABS_TOGGLE.md b/docs/explorations/0282_[x]_WORKSPACE_EDITING_AFFORDANCES_AND_LABS_TOGGLE.md
new file mode 100644
index 000000000..25cde9bfe
--- /dev/null
+++ b/docs/explorations/0282_[x]_WORKSPACE_EDITING_AFFORDANCES_AND_LABS_TOGGLE.md
@@ -0,0 +1,612 @@
+# Workspace Editing Affordances And The Labs Toggle
+
+## Problem Statement
+
+0280 shipped a genuinely malleable shell — movable slot views, saveable
+workspace nodes, three presets — and almost nobody can find it. Three
+distinct discoverability failures stack up:
+
+1. **The renderer is gated behind a localStorage incantation.**
+ `xnet:experiment:layout-tree` has no UI; enabling the malleable shell
+ means opening devtools and typing `localStorage.setItem(...)`. The two
+ older shell flags (`xnet:experiment:quiet-default`,
+ `xnet:experiment:desk-radial`) have the same problem. We deliberately do
+ NOT want to flip the default yet — but "experimental" must mean "one
+ honest toggle away", not "read the source".
+2. **The editing affordances are invisible even when enabled.** The only
+ always-visible hint is a 13px ⇄ icon in a panel header. Panel headers
+ are draggable, but nothing announces it: no grab handle, no cursor
+ change, no lift/ghost styling, no highlighted drop zones, no insertion
+ indicator, no post-drop confirmation. Every affordance-signal pattern
+ the industry converged on is missing.
+3. **Drops only land where a dock is already open.** The drop targets in
+ `ShellFrame` wrap the _rendered_ dock bodies — a closed or empty dock is
+ not a target at all, so "pop this panel out and slot it in over there"
+ silently no-ops on most of the viewport. There is also no way to
+ reorder views _within_ a dock from the UI, and the corner dock's Move
+ popover intermittently swallows its own click (Radix dismissal race,
+ observed during 0280 validation).
+
+The ask: a proper settings toggle for the experiment, and first-class
+pop-out / slot-in affordances so arranging the workspace feels like a
+feature instead of a secret.
+
+## Executive Summary
+
+Three moves, each independently landable:
+
+1. **A Labs section in Settings** backed by a tiny flag registry
+ (`apps/web/src/lib/labs.ts`) that unifies the three existing
+ `xnet:experiment:*` localStorage keys into declarative entries
+ (label, description, stage, reload semantics). Named **Labs** — the
+ habit-tracking feature already owns the word "Experiments"
+ (`/experiments`, `ExperimentsView`). The malleable-shell toggle is the
+ flagship entry, with honest staging copy.
+2. **Always-on affordance upgrades** to the existing drag machinery, in
+ the exact visual language the research consensus prescribes: a grab
+ handle that appears on header hover with `cursor: grab`; a window-level
+ drag lifecycle so that _while any slot drag is in flight_ all four dock
+ regions materialize as highlighted drop zones (closed docks get portal
+ edge strips — this fixes the biggest mechanical gap); drag ghost at
+ reduced opacity; a brief post-drop flash on the landing dock; and the
+ Move menu rebuilt on `DropdownMenu` so its clicks stop racing the
+ popover dismissal.
+3. **An explicit "Arrange" mode** — the iOS-jiggle/Home-Assistant pattern,
+ tuned to xNet's calm aesthetic (outlines and handles, no wiggle). One
+ verb (`Workspace: Customize layout…`, in ⌘K, the workspace switcher,
+ and every Move menu) swaps the shell for a schematic of itself: all
+ regions rendered as labeled, outlined slots; every view a draggable
+ chip with visible handle, tier control and hide button; insertion
+ lines for reordering (the `order` field exists; the UI doesn't);
+ Esc/Done walks out, and a "Save as…" nudge appears if the tree diverged
+ from its preset. Arrange mode is where coarse rearrangement becomes
+ _teachable_ — the affordances don't have to whisper because the user
+ explicitly asked to see them.
+
+No new dependencies: the shell already has a working HTML5 DnD idiom
+(TabBar's drop-edge indicator, `@xnetjs/ui`'s transfer helpers), the menu
+fallback required for accessibility/touch already exists (`slot.move:*`
+commands + Move menu), and desktop-first HTML5 DnD is adequate for this
+iteration. Adopting a pointer-event library (dnd-kit /
+pragmatic-drag-and-drop) is deliberately deferred until arrange mode goes
+touch-first.
+
+## Current State In The Repository
+
+### Flags with no front door
+
+| Flag | Defined in | UI today |
+| ------------------------------- | ------------------------------------------------ | -------- |
+| `xnet:experiment:layout-tree` | `apps/web/src/workbench/experiments.ts` | none |
+| `xnet:experiment:quiet-default` | `apps/web/src/lib/desk.ts` (`QUIET_DEFAULT_KEY`) | none |
+| `xnet:experiment:desk-radial` | `apps/web/src/lib/desk.ts` (`DESK_RADIAL_KEY`) | none |
+
+`apps/web/src/routes/settings.tsx` has twelve sections (`SECTIONS`,
+~line 104) — none is a Labs/flags surface. Naming constraint: the
+`/experiments` route and `apps/web/src/components/experiments/*` are the
+habit-tracker (metrics/observations), so the settings section must not be
+called "Experiments".
+
+### The editing machinery that exists (0280)
+
+- `apps/web/src/workbench/slot-registry.tsx` — component-free registry;
+ every view registers `slot.move::` / `slot.open` /
+ `slot.hide` / `slot.show` palette commands (the keyboard road is done).
+- `apps/web/src/workbench/PanelViewHost.tsx` — `SLOT_DRAG_TYPE` MIME,
+ `draggable` header (no handle icon, no cursor styling, no drag image),
+ `MoveViewMenu` (⇄ button → Radix Popover; the popover's item clicks
+ intermittently lose the race against outside-dismissal — seen live
+ during 0280 validation).
+- `apps/web/src/workbench/ShellFrame.tsx` — `dropProps(region)` adds
+ `onDragOver`/`onDrop` to _open_ dock bodies only; no `dragenter`
+ highlight state, no indicator when a drop is viable, closed docks are
+ untargetable.
+- `apps/web/src/workbench/TabBar.tsx` — the in-house gold standard:
+ per-edge `dropEdge` state renders a 2px insertion indicator
+ (`TabDropIndicator`), exactly the pattern the docks lack.
+- `packages/plugins/src/workspace/layout-tree.ts` — `SlotPlacement.order`
+ is modeled and renumbered on every move, but no UI mutates order
+ directly (`moveSlot` always appends).
+- Tier changes (`pinned`/`summoned`/`hidden`) are palette-only.
+- `apps/web/src/workbench/calm/QuietChrome.tsx` overlays and
+ `SurfaceDock.tsx` strip items are not drag sources at all.
+- Coachmarks (0206, `contributeTips`) and the disclosure ladder (0273)
+ are available carriers for first-run hints.
+
+### The three roads today, honestly graded
+
+| Road | State |
+| ---------------------- | ------------------------------------------------------------ |
+| Keyboard (⌘K commands) | Complete — every verb exists |
+| Pointer (menus) | Present but subtle (13px ⇄), and flaky in the corner card |
+| Pointer (drag) | Technically present, perceptually absent, drops mostly no-op |
+| Touch | Menu only (HTML5 DnD does not fire on most mobile browsers) |
+
+## External Research
+
+Distilled from a 25-product sweep (IDEs, docking frameworks, dashboards,
+experimental-toggle surfaces, DnD libraries); full citations in
+References.
+
+**The recurring affordance patterns** (near-universal consensus):
+
+1. **Grab handle on hover** + `cursor: grab` — Notion's six-dot handle,
+ Obsidian gutters. Signals draggability without permanent clutter.
+2. **Lift/ghost on drag start** — original dims to ~40% opacity, preview
+ carries a shadow (Atlassian spec; Trello's signature 4° tilt).
+3. **Highlighted drop zones on every valid target, for the whole drag** —
+ Photoshop's blue glow, Visual Studio's shaded preview, Home
+ Assistant's color-coded regions. The critical property: _all_ valid
+ targets light up when the drag starts, not just the one under the
+ cursor — which is precisely what fixes our closed-dock problem.
+4. **Insertion indicator** — 2px selected-color line with terminal dots
+ (Atlassian pragmatic-drag-and-drop spec) for ordered placement.
+5. **Explicit edit mode for coarse rearrangement** — iOS jiggle mode,
+ Home Assistant/Grafana dashboard edit: chosen exactly when
+ rearranging is a _secondary_ task, with a clear entry verb and a Done
+ commit. Always-on handles (Notion/Trello) are chosen when drag is the
+ primary interaction. A workspace shell is squarely the former.
+6. **Keyboard/menu fallback with live-region announcements** — Atlassian
+ a11y guidance: handle button opens a move menu (no arrow-key drag);
+ announce "X moved to Y from Z"; restore focus afterwards. VS Code's
+ `View: Move View` is the same idea. We already have this road.
+7. **Magnetic snap + post-drop flash** — ~350ms background ease while
+ hovering, ~700ms flash on the landed item; respect
+ `prefers-reduced-motion`.
+8. **Docking compass/diamond** (Visual Studio) — powerful for arbitrary
+ 2D splits; overkill for our fixed four-dock skeleton. Rejected.
+
+**Experimental-toggle framing**: Gmail Labs / Obsidian core plugins /
+Slack beta all converge on plain toggles grouped in Settings with honest
+per-feature descriptions; auto-apply where possible; no scary iconography
+(Discord's console-only experiments are the anti-pattern for us — that's
+where we are today). Chrome's `chrome://flags` shows what "discoverable
+only by the initiated" looks like — also today's state.
+
+**HTML5 DnD vs pointer libraries**: native DnD is mouse-spec'd — no
+touch events on most mobile browsers (only recent iOS 15+/Chrome 96+
+partially), poor drag-image control, no styling of previews. Libraries
+(dnd-kit, Atlassian pragmatic-drag-and-drop) fix this with pointer
+events + full a11y kits. Our shell is desktop-first, has a complete menu
+fallback for touch, and already owns a working HTML5 idiom (TabBar) —
+so the library adoption is real but not this iteration's blocker.
+
+## Key Findings
+
+1. **We built the verbs, not the visibility.** Every mutation exists as a
+ command; what's missing is 100% presentation: handles, highlights,
+ indicators, and a front door for the flag. This is styling + one state
+ machine, not new architecture.
+2. **The closed-dock gap is the one _mechanical_ bug.** Everything else is
+ perception; "drops silently no-op unless the target dock happens to be
+ open" breaks the core promise. The fix falls straight out of research
+ pattern 3: a drag-lifecycle state that materializes all four dock
+ targets for the duration of any slot drag.
+3. **Arrange mode fits xNet's disclosure grammar.** 0273/0280 established
+ the ladder (L0 bare → summoned → pinned → arranged/saved). An explicit
+ "Customize layout" rung is the same philosophy: density on request,
+ never imposed. It also matches the research decision rule — layout
+ editing is a secondary task, which is exactly when products choose an
+ explicit mode over always-on handles.
+4. **A Labs registry is nearly free and pays compound interest.** Three
+ flags already exist with three ad-hoc readers; the next experiment is
+ inevitable. One declarative list renders the settings section, and the
+ devtools seed/e2e can enumerate it.
+5. **TabBar already solved insertion indicators in-house.** Reordering
+ within a dock is `TabDropIndicator` + an `insertSlot(viewId, region,
+index)` tree op away — the `order` field is modeled and tested.
+6. **The Move menu flake is a real bug** independent of discoverability:
+ Radix `Popover` content clicks race outside-dismissal when the trigger
+ sits inside a hover-expanded strip. `DropdownMenu` (`onSelect` fires
+ before close) is the sturdier primitive.
+
+## Options And Tradeoffs
+
+### A. Where the experiment toggle lives
+
+- **A1 — Labs section in Settings (recommended).** New `SECTIONS` entry
+ (FlaskConical icon) rendering from a flag registry; the Gmail-Labs /
+ Obsidian pattern. Pros: honest, discoverable, scales to future flags.
+ Cons: one more settings section (mitigated: it only renders when the
+ registry is non-empty).
+- **A2 — Bury it in devtools.** Keeps Settings pristine; keeps the
+ feature undiscoverable — fails the brief.
+- **A3 — Flip the default now.** Explicitly out of scope per the brief;
+ parity isn't proven (0280 risk 1).
+
+### B. Affordance model for editing
+
+- **B1 — Always-on handles everywhere (Notion model).** Maximal
+ discoverability, but permanent chrome noise contradicts the calm/quiet
+ doctrine, and drag is a secondary task here — the research decision
+ rule points away from this.
+- **B2 — Explicit Arrange mode only (iOS model).** Clean at rest,
+ teachable when entered; but direct header-drag (which already works)
+ would stay undiscoverable and the closed-dock bug would still bite
+ outside the mode.
+- **B3 — Both, layered (recommended).** Fix the mechanics and add quiet
+ always-on signals (hover handle, cursor, drag-time drop zones,
+ post-drop flash) so direct manipulation works and is _noticeable_; add
+ Arrange mode as the guided, exploratory surface where everything is
+ visible at once. This is exactly VS Code's layering: drag works
+ ambiently, `View: Move View` and the customize submenu exist for
+ deliberate edits.
+
+### C. Drag technology
+
+- **C1 — Keep native HTML5 DnD + in-house idiom (recommended now).**
+ Zero dependencies; consistent with TabBar/canvas ingestion; desktop
+ complete. Touch stays on the menu road (which Atlassian recommends
+ keeping as the a11y road regardless).
+- **C2 — Adopt `pragmatic-drag-and-drop` or `dnd-kit`.** Buys touch drag,
+ custom previews, a11y kit — at the cost of a new dependency and a
+ rewrite of working code. Right move _if/when_ arrange mode needs
+ touch-native drag; the arrange-mode chip UI should be built so its drag
+ layer is swappable.
+- **C3 — Docking framework (dockview et al.).** Re-rejected (0273 D,
+ 0280 C): our skeleton is intentionally fixed; frameworks bring their
+ own layout model.
+
+## Recommendation
+
+Ship **B3 + A1 + C1** in four phases.
+
+### The customize loop
+
+```mermaid
+flowchart LR
+ subgraph settings["Settings › Labs (phase 1)"]
+ TOGGLE["Malleable shell toggle\n(xnet:experiment:layout-tree)"]
+ end
+ subgraph ambient["Ambient affordances (phase 2)"]
+ HANDLE["Grab handle on header hover\ncursor: grab"] --> DRAG["drag start →\nghost + ALL dock zones light up\n(closed docks get edge strips)"]
+ DRAG --> DROP["drop → moveSlot\n+ landing flash"]
+ end
+ subgraph arrange["Arrange mode (phase 3)"]
+ ENTER["Workspace: Customize layout…\n(⌘K · switcher · Move menu · Labs)"] --> SCHEMA["shell renders as schematic:\nlabeled regions, view chips,\ntier + hide controls"]
+ SCHEMA --> REORDER["drag chips between/within regions\ninsertion indicator (phase 4)"]
+ REORDER --> DONE["Done / Esc → live shell\n'Save as…' nudge if diverged"]
+ end
+ TOGGLE -.enables.-> ambient
+ ambient -."discoverability ramp".-> arrange
+ DONE --> SAVE["xnet:Workspace node\n(0280 switcher)"]
+```
+
+### Phase 1 — Labs settings section
+
+`apps/web/src/lib/labs.ts`: a declarative registry unifying the existing
+keys (the flag _definitions_ stay where they are; the registry references
+them):
+
+- `{ key, label, description, stage: 'experimental' | 'preview', appliesOn: 'reload' | 'immediate', learnMoreTip? }`
+- Entries: malleable shell (`LAYOUT_TREE_KEY`, reload), quiet default for
+ new identities (`QUIET_DEFAULT_KEY`), Desk radial menu
+ (`DESK_RADIAL_KEY`).
+- Settings gains a **Labs** section (FlaskConical icon) rendering
+ `SettingToggle` rows from the registry, with a short preamble
+ ("Early features. They may change or go away.") and a "Reload to
+ apply" inline chip when a reload-scoped flag changes — the Obsidian
+ core-plugins feel, not the Discord console feel.
+- The malleable-shell row cross-links: when ON, show a "Customize
+ layout…" button (runs the arrange-mode command); the Appearance
+ Layout row gets one sentence pointing at Labs when the flag is off.
+
+### Phase 2 — Ambient affordances (mechanics + signals)
+
+All in the existing HTML5 idiom:
+
+- **Slot-drag lifecycle store**: a tiny module-level pub/sub
+ (`beginSlotDrag(viewId)` / `endSlotDrag()`; window `dragend`+`drop`
+ listeners as the safety net) — mirrors `setNodeTransfer`'s role for
+ node drags. React hook `useSlotDragActive()`.
+- **Drop zones everywhere while dragging**: `ShellFrame` (and the legacy
+ `DesktopWorkbench` panels, which share `PanelViewHost`) render, during
+ an active slot drag: highlight ring on open dock bodies
+ (`data-drop-active` + accent border token), and **portal edge strips**
+ (~48px, labeled "Left dock" etc.) over the four edges for docks that
+ are closed or empty — research pattern 3. Drop = same
+ `moveSlot`; the dock opens to show the landing (then the post-drop
+ flash, ~700ms, `motion-safe:` only).
+- **Grab handle**: `GripVertical` fades in on panel-header hover next to
+ the title, `cursor-grab active:cursor-grabbing`; `setDragImage` uses
+ the header element so the ghost looks like the thing being moved;
+ source header drops to 40% opacity during the drag.
+- **Menu fix**: `MoveViewMenu` moves from `PopoverRoot` to
+ `DropdownMenu*` primitives (check `@xnetjs/ui` exports; they exist for
+ other menus) — kills the click-swallow race in the corner card.
+- **A11y**: an `aria-live=polite` region announces "Explorer moved to
+ right dock" on every `moveSlot` (command-level, so palette/menu/drag
+ all announce); focus returns to the moved view's header.
+
+The drag lifecycle this phase introduces (and phase 3 reuses):
+
+```mermaid
+stateDiagram-v2
+ [*] --> Rest
+ Rest: Rest — handle hidden\n(hover reveals GripVertical, cursor grab)
+ Lifted: Lifted — ghost follows cursor\nsource at 40% opacity\nALL dock targets visible\n(closed docks = edge strips)
+ Hovering: Hovering a target\n(ring brightens, data-drop-active)
+ Landed: Landed — moveSlot ran\ndock opens, 700ms flash,\naria-live announce, focus restored
+
+ Rest --> Lifted: dragstart (header)\n→ beginSlotDrag(viewId)
+ Lifted --> Hovering: dragover valid region
+ Hovering --> Lifted: dragleave
+ Hovering --> Landed: drop → moveSlot
+ Lifted --> Rest: dragend (cancelled)\n→ endSlotDrag()
+ Landed --> Rest: flash ends
+```
+
+### Phase 3 — Arrange mode
+
+A guided schematic, entered deliberately (research pattern 5), calm
+rather than jiggly:
+
+- **Entry**: command `workspace.customize` ("Workspace: Customize
+ layout…") — registered headlessly like the preset commands; surfaced in
+ ⌘K, the workspace switcher list, each Move menu ("Customize layout…"
+ footer item), and the Labs row. Ephemeral state (`arranging: boolean`)
+ in the workbench store — never persisted (same rule as
+ `discloseLevel`).
+- **Rendering**: `ArrangeOverlay` replaces the frame's content area: a
+ scaled schematic of the region skeleton — five labeled outlined slots
+ (left/right/bottom/corner docks + rail preview), each listing its
+ placed views as chips (icon + label + `GripVertical` + tier badge +
+ hide ✕). Unplaced/hidden views collect in a "Hidden" tray at the
+ bottom. Everything visible at once — the whole tree, teachable in one
+ screen.
+- **Interactions**: drag chips between slots (`moveSlot`) and within a
+ slot (phase 4 reorder); click tier badge to cycle pinned/summoned;
+ ✕ → hidden tray; every chip also has the Move dropdown (keyboard road
+ intact inside the mode). All mutations are the same store
+ actions/commands — arrange mode is pure presentation over them (the
+ 0280 tripwire idiom: no new mutation paths).
+- **Exit**: Done button + Esc (top of the ladder — arrange closes before
+ docks close); if `tree` diverged from its preset/workspace provenance,
+ the exit toast offers "Save as…" (opens the 0280 switcher in save
+ mode).
+- **First-run**: one coachmark (0206) on the grab handle the first time
+ the flag turns on: "Drag this — or press ⌘K and type 'customize'."
+
+### Phase 4 — Reorder within a dock
+
+- `insertSlot(tree, viewId, region, index)` pure op in
+ `packages/plugins/src/workspace/layout-tree.ts` (minor bump; `moveSlot`
+ becomes `insertSlot(…, end)`).
+- Drop-edge insertion indicator on chips (arrange mode) and on the corner
+ strip / bottom-dock header tabs (ambient), lifted from
+ `TabBar.tsx`'s `TabDropIndicator`.
+- New commands `slot.reorder::up|down` for the keyboard road.
+
+### What this explicitly does not do
+
+- No default flip (`layout-tree` stays opt-in; Labs is the front door).
+- No DnD library yet; the arrange-mode drag layer is isolated so C2 can
+ swap in for touch later.
+- No free-form splits/compass docking; the four-dock skeleton stands.
+- No changes to `modes.ts`, routes, or the legacy shells' compositions.
+
+## Example Code
+
+```ts
+// apps/web/src/lib/labs.ts — the registry the Labs section renders
+export interface LabsFlag {
+ key: string
+ label: string
+ description: string
+ stage: 'experimental' | 'preview'
+ /** Whether flipping it needs a reload to take effect. */
+ appliesOn: 'reload' | 'immediate'
+}
+
+export const LABS_FLAGS: LabsFlag[] = [
+ {
+ key: LAYOUT_TREE_KEY,
+ label: 'Malleable shell',
+ description:
+ 'Move panels between docks, arrange the layout, and save it as a workspace. The presets and switcher work either way.',
+ stage: 'experimental',
+ appliesOn: 'reload'
+ }
+ // quiet-default, desk-radial…
+]
+
+export function isLabEnabled(key: string): boolean {
+ try {
+ return localStorage.getItem(key) === '1'
+ } catch {
+ return false
+ }
+}
+
+export function setLabEnabled(key: string, on: boolean): void {
+ try {
+ on ? localStorage.setItem(key, '1') : localStorage.removeItem(key)
+ } catch {
+ /* private mode */
+ }
+}
+```
+
+```ts
+// apps/web/src/workbench/slot-drag.ts — the drag lifecycle (pattern 3)
+type Listener = (active: { viewId: string } | null) => void
+let active: { viewId: string } | null = null
+const listeners = new Set()
+
+export function beginSlotDrag(viewId: string): void {
+ active = { viewId }
+ for (const l of listeners) l(active)
+}
+export function endSlotDrag(): void {
+ if (!active) return
+ active = null
+ for (const l of listeners) l(null)
+}
+// Safety net: HTML5 fires dragend on the SOURCE even for cancelled drags.
+if (typeof window !== 'undefined') {
+ window.addEventListener('dragend', endSlotDrag)
+ window.addEventListener('drop', endSlotDrag)
+}
+export function useSlotDragActive(): { viewId: string } | null {
+ return useSyncExternalStore(
+ (cb) => (listeners.add(cb as Listener), () => listeners.delete(cb as Listener)),
+ () => active
+ )
+}
+```
+
+```tsx
+// ShellFrame: while a slot drag is live, closed docks become edge strips
+{
+ dragActive && !rightOpen && (
+
+ )
+}
+```
+
+## Risks And Open Questions
+
+1. **Settings clutter.** Twelve sections become thirteen. Accepted: Labs
+ only renders when the registry is non-empty, and it _removes_ the
+ worse cost (support burden of "how do I turn this on?").
+2. **Ambient affordances in the legacy renderer.** With the flag off, the
+ legacy CalmShell renders ListPane/Canvas directly (no headers), so
+ ambient drag simply doesn't exist there — fine (the flag gates the
+ feature), but the legacy _workbench_ grid shares `PanelViewHost` and
+ will grow handles whose drops rearrange a tree the legacy renderer
+ only partially reflects. Mitigation: gate the handle/drag-source on
+ `isLayoutTreeEnabled()` so legacy stays inert.
+3. **Edge strips vs existing edge semantics.** Quiet posture already uses
+ edge hot-zones for summoning (0273). Arrange-mode/drag strips must not
+ fight them: drag strips render only during an active slot drag, when
+ summon hot-zones are suppressed (`buttons !== 0` already guards them).
+ Verify on the real quiet posture.
+4. **Popover→DropdownMenu regression risk** in the five places
+ `MoveViewMenu` renders. Small, but the 0280 validation script should
+ re-click all of them.
+5. **`prefers-reduced-motion`**: every new animation (fade-in strips,
+ post-drop flash) must be `motion-safe:`-gated — the motion vocabulary
+ gate (`check-motion-vocab.mjs`, 0199) will enforce the vocabulary but
+ not the gating; review does.
+6. **Does the Desk (canvas home) need arrange-mode treatment too?** Out
+ of scope: the Desk is content (canvas cards), not shell; its editing
+ affordances are 0273 Phase 5's radial-menu track.
+7. **Touch drag** stays menu-only this iteration. If dogfood feedback
+ demands touch drag in arrange mode, that's the C2 trigger
+ (pragmatic-drag-and-drop, whose a11y/announcement patterns we're
+ already adopting manually).
+
+## Implementation Checklist
+
+### Phase 1 — Labs section
+
+- [x] `apps/web/src/lib/labs.ts`: `LabsFlag` registry + `isLabEnabled` /
+ `setLabEnabled`; entries for layout-tree, quiet-default, desk-radial
+ (flag key constants stay in their current homes)
+- [x] Settings: `labs` entry in `SECTIONS` (FlaskConical) +
+ `LabsSettings` panel rendering `SettingToggle` rows, stage badge,
+ preamble copy, and a "Reload to apply" chip for `appliesOn:
+'reload'` changes
+- [x] Appearance → Layout row: one-line pointer to Labs when the
+ malleable shell is off; "Customize layout…" button when on
+- [x] Unit test: registry entries resolve real keys; toggling round-trips
+ localStorage
+
+### Phase 2 — Ambient affordances
+
+- [x] `apps/web/src/workbench/slot-drag.ts` lifecycle
+ (begin/end/useSlotDragActive + window safety net)
+- [x] `PanelViewHost` header: `GripVertical` hover handle, `cursor-grab`,
+ `setDragImage(header)`, 40% source opacity during drag; drag source
+ gated on `isLayoutTreeEnabled()`
+- [x] `ShellFrame`: `data-drop-active` highlight on open dock bodies +
+ portal `DockEdgeStrip`s for closed docks during a live drag;
+ post-drop `motion-safe:` flash on the landing dock
+- [x] `MoveViewMenu`: Popover → DropdownMenu primitives
+- [x] `aria-live` announcement on `moveSlot` (command layer) + focus
+ restoration
+- [x] Tests: lifecycle store; edge-strip render during drag (jsdom
+ drag events); announcement text
+
+### Phase 3 — Arrange mode
+
+- [x] `arranging` ephemeral state + `workspace.customize` headless
+ command (excluded from persistence like `discloseLevel`)
+- [x] `ArrangeOverlay` schematic: labeled region slots, view chips
+ (icon/label/handle/tier badge/hide), hidden tray; all mutations via
+ existing store actions
+- [x] Entry points: ⌘K, workspace switcher row, Move menu footer, Labs
+ row button
+- [x] Exit: Done + Esc (arrange closes first on the ladder); diverged →
+ "Save as…" nudge into the 0280 switcher save flow
+- [x] First-run coachmark on the grab handle (0206), registered only when
+ the flag is on
+- [x] Tests: enter/exit + Esc order; chip move dispatches `moveSlot`;
+ tier cycle; hidden tray round-trip
+
+### Phase 4 — Reorder within a dock
+
+- [x] `insertSlot(tree, viewId, region, index)` in
+ `packages/plugins/src/workspace/layout-tree.ts` + tests; changeset
+ (minor)
+- [x] Store action + `slot.reorder::up|down` commands
+- [x] Insertion indicator on arrange-mode chips and the bottom-dock
+ header tabs (lift `TabDropIndicator`)
+- [x] Changelog fragment (`--tags app`) covering the Labs toggle +
+ arrange mode
+
+## Validation Checklist
+
+- [x] With a fresh profile, the malleable shell can be enabled entirely
+ through UI: Settings → Labs → toggle → reload chip → arrange
+- [x] With the flag off, no drag affordances render anywhere (legacy
+ shells inert) and Labs is the only new surface
+- [x] Dragging any panel header lights up all four dock targets,
+ including closed docks (edge strips), and dropping on a closed dock
+ opens it with the view landed + flash
+- [x] The Move menu works reliably in all hosts (left/bottom docks,
+ corner card ×5 clicks each — the 0280 flake is gone)
+- [x] Arrange mode: every placed view visible as a chip; move/reorder/
+ tier/hide all work by drag AND by menu/keyboard; screen reader
+ announces each move; Esc walks out before closing docks
+- [x] Exiting arrange mode with a diverged tree surfaces the Save-as
+ nudge; saving creates the workspace node (0280 flow)
+- [x] Reordering within a dock persists across reload and round-trips
+ through a saved workspace node
+- [x] All new motion is disabled under `prefers-reduced-motion`
+- [x] Quiet posture: edge summon hot-zones and drag edge strips never
+ fight (manual pass on the live app)
+
+## References
+
+- Prior explorations: `0280_[x]_MALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md`
+ (the machinery), `0273_[x]_QUIET_SURFACE_WORKSPACE_SHELL.md` (disclosure
+ ladder, touch twins), `0206` (coachmarks), `0199` (motion vocabulary)
+- [Atlassian pragmatic-drag-and-drop — design guidelines](https://atlassian.design/components/pragmatic-drag-and-drop/design-guidelines)
+ (drop indicator/flash specs) ·
+ [accessibility guidelines](https://atlassian.design/components/pragmatic-drag-and-drop/accessibility-guidelines)
+ (menu-based move, live regions)
+- [NN/g — Drag-and-Drop usability](https://www.nngroup.com/articles/drag-drop/)
+- [Visual Studio — window layouts & docking diamond](https://learn.microsoft.com/en-us/visualstudio/ide/customizing-window-layouts-in-visual-studio?view=visualstudio) ·
+ [VS Code — custom layout & View: Move View](https://code.visualstudio.com/docs/configure/custom-layout) ·
+ [JetBrains — tool windows](https://www.jetbrains.com/help/idea/manipulating-the-tool-windows.html) ·
+ [Zed — panel system](https://zed.dev/blog/new-panel-system)
+- [Photoshop — panel docking zones](https://helpx.adobe.com/photoshop/desktop/get-started/learn-the-basics/dock-undeck-panels.html)
+- [iOS — Home Screen editing (jiggle mode)](https://support.apple.com/guide/iphone/move-apps-and-widgets-on-the-home-screen-iphd2fc8ce30/ios) ·
+ [Home Assistant — dashboard edit mode](https://www.home-assistant.io/dashboards/) ·
+ [Grafana — dashboard editing](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/modify-dashboard-settings/)
+- [Notion — block drag handles](https://www.notion.com/help/writing-and-editing-basics) ·
+ [Obsidian — core plugins toggles](https://obsidian.md/help/plugins) ·
+ [Chrome flags](https://developer.chrome.com/docs/web-platform/chrome-flags)
+- [dnd-kit](https://dndkit.com/) ·
+ [MDN — setDragImage](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/setDragImage) ·
+ [drag-drop-touch polyfill](https://github.com/drag-drop-touch-js/dragdroptouch)
+- In-repo prior art: `apps/web/src/workbench/TabBar.tsx`
+ (`TabDropIndicator`), `@xnetjs/ui` node-transfer helpers,
+ `packages/devtools` panel registry tiers
diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts
index 26f1f8695..9618d94d0 100644
--- a/packages/plugins/src/index.ts
+++ b/packages/plugins/src/index.ts
@@ -89,6 +89,7 @@ export { TypedRegistry, ContributionRegistry } from './contributions'
// Workspace layout primitives (exploration 0280) — one grouped block
export {
createPresetTree,
+ insertSlot,
moveSlot,
parseWorkspacePayload,
placementOf,
diff --git a/packages/plugins/src/workspace/index.ts b/packages/plugins/src/workspace/index.ts
index 4b7f64f46..8caf448af 100644
--- a/packages/plugins/src/workspace/index.ts
+++ b/packages/plugins/src/workspace/index.ts
@@ -5,6 +5,7 @@
*/
export {
createPresetTree,
+ insertSlot,
moveSlot,
parseWorkspacePayload,
placementOf,
diff --git a/packages/plugins/src/workspace/layout-tree.test.ts b/packages/plugins/src/workspace/layout-tree.test.ts
index 1c26773d4..f40e0de36 100644
--- a/packages/plugins/src/workspace/layout-tree.test.ts
+++ b/packages/plugins/src/workspace/layout-tree.test.ts
@@ -7,6 +7,7 @@
import { describe, expect, it } from 'vitest'
import {
createPresetTree,
+ insertSlot,
moveSlot,
parseWorkspacePayload,
placementOf,
@@ -119,3 +120,46 @@ describe('workspace payload round trip', () => {
expect(parseWorkspacePayload({ name: 7 })).toBeNull()
})
})
+
+describe('insertSlot (0282)', () => {
+ it('reorders within a region with dense orders', () => {
+ const tree = createPresetTree('bench')
+ // dock.bottom: shelf, capture, notifications, sync, console
+ const reordered = insertSlot(tree, 'console', 'dock.bottom', 0)
+ expect(slotsIn(reordered, 'dock.bottom').map((p) => p.viewId)).toEqual([
+ 'console',
+ 'shelf',
+ 'capture',
+ 'notifications',
+ 'sync'
+ ])
+ expect(slotsIn(reordered, 'dock.bottom').map((p) => p.order)).toEqual([0, 1, 2, 3, 4])
+ })
+
+ it('inserts cross-region at the given index and clamps', () => {
+ const tree = createPresetTree('bench')
+ const moved = insertSlot(tree, 'console', 'dock.left', 1)
+ expect(slotsIn(moved, 'dock.left').map((p) => p.viewId)[1]).toBe('console')
+ const clamped = insertSlot(tree, 'console', 'dock.left', 99)
+ expect(
+ slotsIn(clamped, 'dock.left')
+ .map((p) => p.viewId)
+ .at(-1)
+ ).toBe('console')
+ })
+
+ it('no-ops on unknown views and same-position inserts', () => {
+ const tree = createPresetTree('bench')
+ expect(insertSlot(tree, 'nope', 'dock.left', 0)).toBe(tree)
+ const bottom = slotsIn(tree, 'dock.bottom').map((p) => p.viewId)
+ const samePos = insertSlot(tree, 'shelf', 'dock.bottom', 0)
+ expect(slotsIn(samePos, 'dock.bottom').map((p) => p.viewId)).toEqual(bottom)
+ })
+
+ it('moveSlot is insertSlot-at-end', () => {
+ const tree = createPresetTree('bench')
+ expect(slotsIn(moveSlot(tree, 'console', 'dock.left'), 'dock.left')).toEqual(
+ slotsIn(insertSlot(tree, 'console', 'dock.left', 99), 'dock.left')
+ )
+ })
+})
diff --git a/packages/plugins/src/workspace/layout-tree.ts b/packages/plugins/src/workspace/layout-tree.ts
index 5a5c9c31d..9ba0f8108 100644
--- a/packages/plugins/src/workspace/layout-tree.ts
+++ b/packages/plugins/src/workspace/layout-tree.ts
@@ -208,18 +208,44 @@ function renumber(placements: SlotPlacement[]): SlotPlacement[] {
}
/**
- * Move a view to another region (appended last), keeping its tier.
- * No-op when the view is absent or already there.
+ * Insert a view into a region at a specific index (0282 phase 4),
+ * keeping its tier. Works for cross-region moves AND within-region
+ * reorders; the index is clamped, and orders stay dense on both sides.
+ * No-op when the view is absent or nothing would change.
*/
-export function moveSlot(tree: LayoutTree, viewId: string, to: RegionId): LayoutTree {
+export function insertSlot(
+ tree: LayoutTree,
+ viewId: string,
+ to: RegionId,
+ index: number
+): LayoutTree {
const from = regionOf(tree, viewId)
- if (!from || from === to) return tree
+ if (!from) return tree
const placement = tree.regions[from].find((entry) => entry.viewId === viewId)
if (!placement) return tree
+
const regions = cloneRegions(tree.regions)
regions[from] = renumber(regions[from].filter((entry) => entry.viewId !== viewId))
- regions[to] = renumber([...regions[to], { ...placement, order: Number.MAX_SAFE_INTEGER }])
- return { ...tree, regions }
+ const target = [...regions[to]].sort((a, b) => a.order - b.order)
+ const clamped = Math.max(0, Math.min(index, target.length))
+ target.splice(clamped, 0, { ...placement, order: 0 })
+ regions[to] = target.map((entry, position) => ({ ...entry, order: position }))
+
+ const changed =
+ from !== to ||
+ regions[to].findIndex((entry) => entry.viewId === viewId) !==
+ tree.regions[to].findIndex((entry) => entry.viewId === viewId)
+ return changed ? { ...tree, regions } : tree
+}
+
+/**
+ * Move a view to another region (appended last), keeping its tier.
+ * No-op when the view is absent or already there.
+ */
+export function moveSlot(tree: LayoutTree, viewId: string, to: RegionId): LayoutTree {
+ const from = regionOf(tree, viewId)
+ if (!from || from === to) return tree
+ return insertSlot(tree, viewId, to, tree.regions[to].length)
}
/** Change a placed view's disclosure tier. No-op when absent/unchanged. */
diff --git a/site/src/data/changelog/2026-07-08-arrange-your-workspace-by-dragging-and-f.json b/site/src/data/changelog/2026-07-08-arrange-your-workspace-by-dragging-and-f.json
new file mode 100644
index 000000000..ae4ffe631
--- /dev/null
+++ b/site/src/data/changelog/2026-07-08-arrange-your-workspace-by-dragging-and-f.json
@@ -0,0 +1,8 @@
+{
+ "id": "2026-07-08-arrange-your-workspace-by-dragging-and-f",
+ "date": "July 8, 2026",
+ "title": "Arrange your workspace by dragging — and find it all in Settings › Labs",
+ "summary": "The malleable shell now has a real front door: a Labs section in Settings toggles it (and the other experiments) without any console tricks. Once on, panels show a grab handle, every dock lights up as a drop target while you drag (closed docks appear as edge strips), and a new Customize layout mode lays the whole shell out as draggable chips you can move, reorder, pin or hide — then save as a named workspace.",
+ "highlights": [],
+ "tags": ["app"]
+}