diff --git a/.changeset/insert-slot-0282.md b/.changeset/insert-slot-0282.md new file mode 100644 index 000000000..9c939a926 --- /dev/null +++ b/.changeset/insert-slot-0282.md @@ -0,0 +1,5 @@ +--- +'@xnetjs/plugins': minor +--- + +`insertSlot(tree, viewId, region, index)` joins the workspace layout primitives (exploration 0282): positional insertion for within-dock reorders and cross-region moves; `moveSlot` is now insert-at-end. Purely additive. diff --git a/.storybook/shims/xnet-plugins-browser.ts b/.storybook/shims/xnet-plugins-browser.ts index 0f9e4aa43..1081d20fc 100644 --- a/.storybook/shims/xnet-plugins-browser.ts +++ b/.storybook/shims/xnet-plugins-browser.ts @@ -77,6 +77,7 @@ export type { // shell modules import these through the @xnetjs/plugins alias. export { createPresetTree, + insertSlot, isPresetWorkspaceId, moveSlot, parseWorkspacePayload, diff --git a/apps/web/src/lib/labs.test.ts b/apps/web/src/lib/labs.test.ts new file mode 100644 index 000000000..516c755d1 --- /dev/null +++ b/apps/web/src/lib/labs.test.ts @@ -0,0 +1,36 @@ +/** + * Labs registry (0282): entries resolve the real flag keys, and toggling + * round-trips localStorage exactly the way the feature readers check it. + */ +import { describe, expect, it } from 'vitest' +import { isLayoutTreeEnabled, LAYOUT_TREE_KEY } from '../workbench/experiments' +import { DESK_RADIAL_KEY, isDeskRadialEnabled, QUIET_DEFAULT_KEY } from './desk' +import { isLabEnabled, LABS_FLAGS, setLabEnabled } from './labs' + +describe('labs registry', () => { + it('covers the three shipped experiment flags with real keys', () => { + const keys = LABS_FLAGS.map((flag) => flag.key) + expect(keys).toContain(LAYOUT_TREE_KEY) + expect(keys).toContain(QUIET_DEFAULT_KEY) + expect(keys).toContain(DESK_RADIAL_KEY) + // Every entry has honest copy — no empty descriptions. + for (const flag of LABS_FLAGS) { + expect(flag.label.length).toBeGreaterThan(0) + expect(flag.description.length).toBeGreaterThan(20) + } + }) + + it('round-trips through the same storage the feature readers check', () => { + setLabEnabled(LAYOUT_TREE_KEY, true) + expect(isLabEnabled(LAYOUT_TREE_KEY)).toBe(true) + expect(isLayoutTreeEnabled()).toBe(true) + + setLabEnabled(DESK_RADIAL_KEY, true) + expect(isDeskRadialEnabled()).toBe(true) + + setLabEnabled(LAYOUT_TREE_KEY, false) + expect(isLayoutTreeEnabled()).toBe(false) + expect(localStorage.getItem(LAYOUT_TREE_KEY)).toBeNull() + setLabEnabled(DESK_RADIAL_KEY, false) + }) +}) diff --git a/apps/web/src/lib/labs.ts b/apps/web/src/lib/labs.ts new file mode 100644 index 000000000..58ddce3a1 --- /dev/null +++ b/apps/web/src/lib/labs.ts @@ -0,0 +1,68 @@ +/** + * Labs — the registry behind Settings › Labs (exploration 0282). + * + * Every `xnet:experiment:*` localStorage flag gets one declarative entry + * here, and the Labs settings section renders them as honest toggles — + * the Obsidian-core-plugins pattern, not the chrome://flags incantation. + * Flag KEY constants stay where their features live (desk.ts, + * workbench/experiments.ts); this file only aggregates them. + * + * Named "Labs" and not "Experiments" — the habit tracker owns that word + * (`/experiments`, ExperimentsView). + */ +import { LAYOUT_TREE_KEY } from '../workbench/experiments' +import { DESK_RADIAL_KEY, QUIET_DEFAULT_KEY } from './desk' + +export interface LabsFlag { + /** The localStorage key (an existing `xnet:experiment:*` constant). */ + key: string + label: string + description: string + /** Honest staging: experimental = may change/vanish; preview = stabilizing. */ + 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 named workspace. The presets and switcher work either way; this enables the movable-panel shell itself.', + stage: 'experimental', + appliesOn: 'reload' + }, + { + key: QUIET_DEFAULT_KEY, + label: 'Quiet surface by default', + description: + 'New identities start on the bare quiet surface (Desk + corner chrome) instead of the calm shell. Existing identities are never moved.', + stage: 'preview', + appliesOn: 'reload' + }, + { + key: DESK_RADIAL_KEY, + label: 'Desk radial menu', + description: 'Long-press a Desk card for a radial quick-action menu while the gesture settles.', + stage: 'experimental', + appliesOn: 'immediate' + } +] + +export function isLabEnabled(key: string): boolean { + try { + return localStorage.getItem(key) === '1' + } catch { + return false + } +} + +export function setLabEnabled(key: string, on: boolean): void { + try { + if (on) localStorage.setItem(key, '1') + else localStorage.removeItem(key) + } catch { + /* storage unavailable (private mode) — the toggle just won't stick */ + } +} diff --git a/apps/web/src/plugins/workspace-agent-module.ts b/apps/web/src/plugins/workspace-agent-module.ts index be9108659..936cfcf32 100644 --- a/apps/web/src/plugins/workspace-agent-module.ts +++ b/apps/web/src/plugins/workspace-agent-module.ts @@ -13,6 +13,7 @@ */ import type { FeatureModule, ModuleCapabilities } from '@xnetjs/plugins' import { evaluateInstallConsent, getCommandRegistry, scaffoldPlugin } from '@xnetjs/plugins' +import { isLayoutTreeEnabled } from '../workbench/experiments' import { PRESET_IDS, REGION_IDS, @@ -254,7 +255,15 @@ export function registerWorkspaceCommands(): () => void { title: `Workspace: Preset: ${PRESET_COMMAND_TITLES[preset]}`, run: () => useWorkbench.getState().applyPreset(preset) }) - ) + ), + // Arrange mode (0282): the shell as an editable schematic. Only + // meaningful when the malleable shell renders the tree. + registry.register({ + id: 'workspace.customize', + title: 'Workspace: Customize layout…', + when: () => isLayoutTreeEnabled(), + run: () => useWorkbench.getState().setArranging(true) + }) ] return () => { for (const disposable of disposables) disposable.dispose() diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 02fd6239b..7777cd124 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -15,6 +15,7 @@ import { MeetingEngineSettings } from '@xnetjs/views' import { Palette, Database, + FlaskConical, Mic, Info, Layers, @@ -50,10 +51,12 @@ import { useDerivedData } from '../lib/data-dignity' import { getTelemetryCollector } from '../lib/error-reporter' import { persistedHubUrl, setPersistedHubUrl } from '../lib/hub-url' import { identityManager, logout } from '../lib/identity' +import { isLabEnabled, LABS_FLAGS, setLabEnabled } from '../lib/labs' import { createLeavePorts, downloadLeaveBundle, type LeaveDeps } from '../lib/leave' import { isSentryConfigured } from '../lib/sentry' import { useConsent } from '../lib/use-consent' import { WINDDOWN_DURATION_CHOICES, useWinddownPreferences } from '../lib/winddown' +import { LAYOUT_TREE_KEY } from '../workbench/experiments' import { presetForWorkspaceId } from '../workbench/layout-tree' import { useWorkbench } from '../workbench/state' @@ -70,6 +73,7 @@ export const Route = createFileRoute('/settings')({ type SettingsSection = | 'profile' | 'appearance' + | 'labs' | 'dictation' | 'safety' | 'data' @@ -104,6 +108,7 @@ const LEAVE_DEPS: LeaveDeps = { const SECTIONS: SectionConfig[] = [ { id: 'profile', label: 'Profile', icon: }, { id: 'appearance', label: 'Appearance', icon: }, + { id: 'labs', label: 'Labs', icon: }, { id: 'dictation', label: 'Dictation & Meetings', icon: }, { id: 'safety', label: 'Content & Safety', icon: }, { id: 'data', label: 'Data', icon: }, @@ -165,6 +170,7 @@ function SettingsPage() {
{activeSection === 'profile' && } {activeSection === 'appearance' && } + {activeSection === 'labs' && } {activeSection === 'dictation' && } {activeSection === 'safety' && (
@@ -185,6 +191,80 @@ function SettingsPage() { ) } +// ─── Labs Settings ──────────────────────────────────────────────────────────── + +/** + * Labs (exploration 0282): the front door for `xnet:experiment:*` flags. + * Rows render from the declarative registry in lib/labs.ts; reload-scoped + * flags surface a "Reload to apply" chip after a change instead of + * restarting the app out from under the user. + */ +function LabsSettings() { + // localStorage isn't reactive; mirror it into state keyed by flag. + const [enabled, setEnabled] = useState>(() => + Object.fromEntries(LABS_FLAGS.map((flag) => [flag.key, isLabEnabled(flag.key)])) + ) + const [needsReload, setNeedsReload] = useState(false) + + const toggle = (key: string, appliesOn: 'reload' | 'immediate') => (checked: boolean) => { + setLabEnabled(key, checked) + setEnabled((prev) => ({ ...prev, [key]: checked })) + if (appliesOn === 'reload') setNeedsReload(true) + } + + return ( + + + {LABS_FLAGS.map((flag) => ( +
+ + + {flag.stage} + + {flag.key === LAYOUT_TREE_KEY && (enabled[flag.key] ?? false) && !needsReload && ( +
+ +
+ )} +
+ ))} +
+ {needsReload && ( +
+ Changes apply after a reload. + +
+ )} +
+ ) +} + // ─── Appearance Settings ────────────────────────────────────────────────────── /** @@ -232,7 +312,11 @@ function AppearanceSettings() {
{ + useWorkbench.getState().applyPreset('bench') + useWorkbench.getState().setArranging(true) +}) + +describe('ArrangeOverlay', () => { + it('renders every dock as a labeled slot with its chips', () => { + render() + expect(screen.getByRole('region', { name: 'left dock' })).toBeTruthy() + expect(screen.getByRole('region', { name: 'corner dock' })).toBeTruthy() + // Explorer chip sits in the left dock slot. + const left = screen.getByRole('region', { name: 'left dock' }) + expect(left.querySelector('[data-arrange-chip="explorer"]')).toBeTruthy() + }) + + it('tier toggle and hide go through setSlotTier', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Hide Explorer' })) + const tree = useWorkbench.getState().tree + const placement = tree.regions['dock.left'].find((p) => p.viewId === 'explorer') + expect(placement?.tier).toBe('hidden') + // The hidden tray now offers it back. + expect(screen.getByRole('region', { name: 'Hidden views' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Show Explorer' })) + expect( + useWorkbench.getState().tree.regions['dock.left'].find((p) => p.viewId === 'explorer')?.tier + ).toBe('summoned') + }) + + it('Esc with an unchanged tree exits arrange mode directly', () => { + render() + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ) + }) + expect(useWorkbench.getState().arranging).toBe(false) + }) + + it('exiting with a changed tree surfaces the save nudge first', () => { + render() + act(() => { + useWorkbench.getState().moveSlot('console', 'dock.corner') + }) + fireEvent.click(screen.getByRole('button', { name: /Done/ })) + // Still arranging — the nudge is up. + expect(useWorkbench.getState().arranging).toBe(true) + expect(screen.getByText('Keep this arrangement?')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(useWorkbench.getState().arranging).toBe(false) + expect(regionOf(useWorkbench.getState().tree, 'console')).toBe('dock.corner') + }) +}) diff --git a/apps/web/src/workbench/ArrangeOverlay.tsx b/apps/web/src/workbench/ArrangeOverlay.tsx new file mode 100644 index 000000000..c7051a279 --- /dev/null +++ b/apps/web/src/workbench/ArrangeOverlay.tsx @@ -0,0 +1,308 @@ +/** + * ArrangeOverlay — arrange mode (exploration 0282 phase 3). + * + * The iOS-jiggle pattern minus the jiggle: entered deliberately + * (`workspace.customize`), the shell renders as a schematic of its own + * layout tree — every dock a labeled, outlined slot; every view a chip + * with a visible grab handle, a tier toggle and a hide control; hidden + * views collected in a tray. Everything is teachable at once because the + * user explicitly asked to see it. + * + * All mutations go through the SAME store actions the palette, menus and + * drags use (`moveSlot` / `setSlotTier`) — this overlay is presentation + * only, and the SlotAnnouncer keeps announcing/flashing beneath it. + * Esc/Done exits; if the tree changed while arranging, the exit surfaces + * a "Save as…" nudge into the 0280 workspace switcher. + */ +import type { SlotContribution } from '@xnetjs/plugins' +import { getCommandRegistry } from '@xnetjs/plugins' +import { Check, Eye, EyeOff, GripVertical, LayoutGrid, Pin, X } from 'lucide-react' +import { useEffect, useRef, useState, type DragEvent, type JSX } from 'react' +import { slotsIn, type LayoutTree, type RegionId, type SlotTier } from './layout-tree' +import { MoveViewMenu, SLOT_DRAG_TYPE } from './PanelViewHost' +import { beginSlotDrag, endSlotDrag } from './slot-drag' +import { getSlotView, movableRegionsFor } from './slot-registry' +import { regionLabel } from './SlotAnnouncer' +import { useWorkbench } from './state' + +/** The dock regions the schematic lays out (rail/status shown read-only). */ +const DOCK_REGIONS: RegionId[] = ['dock.left', 'dock.right', 'dock.bottom', 'dock.corner'] + +function canOccupy(view: SlotContribution | undefined, region: RegionId): boolean { + if (!view) return false + return movableRegionsFor(view).some((entry) => entry.region === region) +} + +function tierGlyph(tier: SlotTier): JSX.Element { + if (tier === 'pinned') return + return +} + +/** One view as a draggable, editable chip. */ +function ViewChip({ + viewId, + tier, + region, + index +}: { + viewId: string + tier: SlotTier + /** Region + index enable within-dock reorder drops (0282 phase 4). */ + region?: RegionId + index?: number +}) { + const setSlotTier = useWorkbench((state) => state.setSlotTier) + const insertSlot = useWorkbench((state) => state.insertSlot) + const [dropEdge, setDropEdge] = useState<'before' | 'after' | null>(null) + const view = getSlotView(viewId) + if (!view) return null + const Icon = typeof view.icon === 'function' ? view.icon : LayoutGrid + const nextTier: SlotTier = tier === 'pinned' ? 'summoned' : 'pinned' + const reorderable = region !== undefined && index !== undefined + + return ( +
{ + event.dataTransfer.setData(SLOT_DRAG_TYPE, viewId) + event.dataTransfer.effectAllowed = 'move' + beginSlotDrag(viewId) + }} + onDragEnd={endSlotDrag} + onDragOver={(event) => { + if (!reorderable || !event.dataTransfer.types.includes(SLOT_DRAG_TYPE)) return + event.preventDefault() + event.stopPropagation() + const rect = event.currentTarget.getBoundingClientRect() + setDropEdge(event.clientX < rect.left + rect.width / 2 ? 'before' : 'after') + }} + onDragLeave={() => setDropEdge(null)} + onDrop={(event) => { + if (!reorderable || dropEdge === null) return + event.stopPropagation() + setDropEdge(null) + const dragged = event.dataTransfer.getData(SLOT_DRAG_TYPE) + if (!dragged || dragged === viewId || !canOccupy(getSlotView(dragged), region)) return + insertSlot(dragged, region, index + (dropEdge === 'after' ? 1 : 0)) + }} + className="relative flex cursor-grab items-center gap-1.5 rounded-lg border border-hairline bg-surface-1 px-2 py-1.5 text-xs text-ink-1 shadow-sm transition-colors duration-fast ease-out hover:border-ink-3 active:cursor-grabbing" + > + {dropEdge && ( + + )} + + + {view.label} + {tier !== 'hidden' && ( + + )} + {tier === 'hidden' ? ( + + ) : ( + + )} + {/* The menu road stays available inside the mode (keyboard + tap). */} + +
+ ) +} + +/** One dock region as a labeled drop slot. */ +function RegionSlot({ tree, region }: { tree: LayoutTree; region: RegionId }) { + const moveSlot = useWorkbench((state) => state.moveSlot) + const setSlotTier = useWorkbench((state) => state.setSlotTier) + const [hovered, setHovered] = useState(0) + // Indices must come from the UNFILTERED region order — hidden + // placements keep their slot in the array, and insertSlot indexes it. + const ordered = slotsIn(tree, region) + const placements = ordered + .map((placement, index) => ({ placement, index })) + .filter(({ placement }) => placement.tier !== 'hidden') + + const onDrop = (event: DragEvent) => { + setHovered(0) + const viewId = event.dataTransfer.getData(SLOT_DRAG_TYPE) + if (!viewId || !canOccupy(getSlotView(viewId), region)) return + moveSlot(viewId, region) + // A chip dragged out of the hidden tray becomes visible where it lands + // (re-read the store: the snapshot from before the move is stale). + const landed = useWorkbench + .getState() + .tree.regions[region]?.find((placement) => placement.viewId === viewId) + if (landed?.tier === 'hidden') setSlotTier(viewId, 'summoned') + } + + return ( +
0 ? 'border-accent-ink bg-surface-2' : 'border-hairline bg-surface-1/60' + }`} + onDragOver={(event) => { + if (event.dataTransfer.types.includes(SLOT_DRAG_TYPE)) event.preventDefault() + }} + onDragEnter={(event) => { + if (event.dataTransfer.types.includes(SLOT_DRAG_TYPE)) setHovered((n) => n + 1) + }} + onDragLeave={() => setHovered((n) => Math.max(0, n - 1))} + onDrop={onDrop} + > +

+ {regionLabel(region)} +

+
+ {placements.map(({ placement, index }) => ( + + ))} + {placements.length === 0 && ( + Drop views here + )} +
+
+ ) +} + +/** Views hidden everywhere, ready to be dragged back in. */ +function HiddenTray({ tree }: { tree: LayoutTree }) { + const hidden = DOCK_REGIONS.flatMap((region) => + slotsIn(tree, region, 'hidden').map((placement) => placement.viewId) + ) + if (hidden.length === 0) return null + return ( +
+

Hidden

+
+ {hidden.map((viewId) => ( + + ))} +
+
+ ) +} + +export function ArrangeOverlay(): JSX.Element { + const tree = useWorkbench((state) => state.tree) + const setArranging = useWorkbench((state) => state.setArranging) + const [nudge, setNudge] = useState(false) + // Snapshot at entry: exiting with a changed tree earns the save nudge. + const entryTree = useRef(tree) + + const exit = () => { + if (entryTree.current !== useWorkbench.getState().tree) { + setNudge(true) + return + } + setArranging(false) + } + + // Esc exits arrange BEFORE the shell ladder closes docks (capture + + // preventDefault, which useShellEscape respects). + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return + event.preventDefault() + exit() + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+
+
+

Customize layout

+

+ 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 && }
@@ -224,6 +309,7 @@ export function ShellFrame({ children }: { children: ReactNode }) { const tree = useWorkbench((state) => state.tree) const mode = useWorkbench((state) => state.mode) + const arranging = useWorkbench((state) => state.arranging) if (mode === 'zen') { return ( @@ -235,11 +321,15 @@ export function ShellFrame({ children }: { children: ReactNode }) { return ( - {tree.chrome === 'quiet' ? ( - {children} - ) : ( - {children} - )} +
+ {tree.chrome === 'quiet' ? ( + {children} + ) : ( + {children} + )} + {/* Arrange mode (0282): the shell as an editable schematic. */} + {arranging && } +
) } 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"] +}