diff --git a/docs/features/editor-preferences.md b/docs/features/editor-preferences.md
index cec9814b5..73707e70a 100644
--- a/docs/features/editor-preferences.md
+++ b/docs/features/editor-preferences.md
@@ -274,10 +274,15 @@ The Settings → Preferences screen renders this list automatically from the cat
| Layers panel | `layersShowClasses` | boolean | `true` | `TreeNode.tsx` |
| Layers panel | `layersAutoExpandSelected` | boolean | `true` | `DomPanel.tsx` selection effect |
| Layers panel | `layersSmoothScroll` | boolean | `true` | `DomPanel.tsx` scroll handler |
+| Layers panel | `layersArrowKeyReorder` | boolean | `false` | `keybindings.ts` / `shortcutDispatch.ts` |
| Properties panel | `propertiesSmoothScroll` | boolean | `true` | `StyleSurface.tsx` + `PropertiesPanel.tsx` |
| Properties panel | `propertiesSectionsExpanded`| boolean | `true` | `StyleSectionsEditor.tsx`, `StyleSurface.tsx`, `CustomPropertiesSection.tsx` |
| Command palette | `spotlightTelemetryEnabled` | boolean | `false` | command palette usage tracking |
+### Arrow-key layer reordering
+
+`layersArrowKeyReorder` is an opt-in editor preference. Enable it under `Settings → Preferences → Layers panel` to use plain `↑` and `↓` on the canvas or Layers tree to move the selected layer one position within its current parent. A multi-selection of sibling layers moves as one ordered block and is recorded as one undo step; selections spanning different parents or containing a locked layer are left unchanged. The existing `Move layer up` and `Move layer down` commands in Spotlight remain available through `⌘K` / `Ctrl+K` regardless of this preference. Modifier keys, editable fields, inline text editing, and the Alt/Option inspect ladder are excluded.
+
### Confirm-before-delete flow
`confirmBeforeDelete` runs through a single shared `` mounted in `AdminCanvasLayout`. Components call `useConfirmDelete()` and pass a `commit` callback:
diff --git a/docs/features/spotlight.md b/docs/features/spotlight.md
index 2693287d0..88e0d7389 100644
--- a/docs/features/spotlight.md
+++ b/docs/features/spotlight.md
@@ -302,7 +302,7 @@ Used by destructive commands: delete user, sign out all devices, revoke session,
| ? | Show all keybindings |
| Custom command shortcuts | Per-command entry in `keybindings.ts` |
-Selected-layer shortcuts are command shortcuts too. `⌘C` / `Ctrl+C`, `⌘X` / `Ctrl+X`, `⌘V` / `Ctrl+V`, and `⌘D` / `Ctrl+D` run when focus is on the canvas or the Layers tree. `⌘⌫` / `Ctrl+Backspace` deletes the selected layer from either surface through the normal delete confirmation flow; plain Delete / Backspace remains accepted by the canvas handler for selected canvas nodes.
+Selected-layer shortcuts are command shortcuts too. `⌘C` / `Ctrl+C`, `⌘X` / `Ctrl+X`, `⌘V` / `Ctrl+V`, and `⌘D` / `Ctrl+D` run when focus is on the canvas or the Layers tree. `⌘⌫` / `Ctrl+Backspace` deletes the selected layer from either surface through the normal delete confirmation flow; plain Delete / Backspace remains accepted by the canvas handler for selected canvas nodes. When the `Move layers with Arrow keys` editor preference is enabled, plain `↑` / `↓` moves the selected layer one sibling position within its current parent. Multi-selected siblings move as one ordered block in a single undo step; selections spanning different parents or containing a locked layer are left unchanged. The Arrow-key binding is disabled by default and ignores modifiers, editable fields, inline text editing, and the Alt/Option inspect ladder.
The keybindings registry is **the single source of truth** for shortcuts — gated by `keybindings-registry-single-source.test.ts`. Register each command shortcut in `keybindings.ts`; component-owned handlers may consume those registered bindings when a surface needs local selection or confirmation behavior, but they must not hard-code a second shortcut definition.
diff --git a/src/__tests__/settings/settingsModal.test.tsx b/src/__tests__/settings/settingsModal.test.tsx
index 8c4edd2d9..63f81674f 100644
--- a/src/__tests__/settings/settingsModal.test.tsx
+++ b/src/__tests__/settings/settingsModal.test.tsx
@@ -483,7 +483,7 @@ describe('SettingsModal — PreferencesSection toggles', () => {
openModal('preferences')
render()
const switches = screen.getAllByRole('switch')
- expect(switches.length).toBe(11)
+ expect(switches.length).toBe(12)
})
it('Hover-preview toggle has aria-checked="true" by default', () => {
diff --git a/src/__tests__/settings/settingsSections.test.tsx b/src/__tests__/settings/settingsSections.test.tsx
index 4e6b027a4..f29ebe048 100644
--- a/src/__tests__/settings/settingsSections.test.tsx
+++ b/src/__tests__/settings/settingsSections.test.tsx
@@ -56,13 +56,13 @@ describe('PreferencesSection — catalog-driven rendering', () => {
// Boolean preferences currently declared in `admin/pages/site/preferences/catalog.ts`:
// hoverPreview, confirmBeforeDelete,
// layersShowIcon, layersShowTag, layersShowClasses,
- // layersAutoExpandSelected, layersSmoothScroll,
+ // layersAutoExpandSelected, layersSmoothScroll, layersArrowKeyReorder,
// dimInactiveBreakpoints, propertiesSmoothScroll,
// propertiesSectionsExpanded,
// spotlightTelemetryEnabled ← Phase 6: opt-in command-usage telemetry
// Adding/removing a boolean preference is one catalog edit and this
// assertion updates with it.
- expect(screen.getAllByRole('switch')).toHaveLength(11)
+ expect(screen.getAllByRole('switch')).toHaveLength(12)
expect(screen.getByRole('switch', { name: /preview suggestions on hover/i })).toBeDefined()
expect(screen.getByRole('switch', { name: /confirm before deleting/i })).toBeDefined()
expect(screen.getByRole('switch', { name: /show module icon/i })).toBeDefined()
@@ -70,6 +70,7 @@ describe('PreferencesSection — catalog-driven rendering', () => {
expect(screen.getByRole('switch', { name: /show class names/i })).toBeDefined()
expect(screen.getByRole('switch', { name: /auto-expand on selection/i })).toBeDefined()
expect(screen.getByRole('switch', { name: /smooth scroll to selected/i })).toBeDefined()
+ expect(screen.getByRole('switch', { name: /move layers with arrow keys/i })).toBeDefined()
expect(screen.getByRole('switch', { name: /dim inactive viewports/i })).toBeDefined()
expect(screen.getByRole('switch', { name: /smooth scroll on tab change/i })).toBeDefined()
expect(screen.getByRole('switch', { name: /expand style sections by default/i })).toBeDefined()
diff --git a/src/__tests__/spotlight/layersCommands.test.ts b/src/__tests__/spotlight/layersCommands.test.ts
index ac8414d2f..645909ca3 100644
--- a/src/__tests__/spotlight/layersCommands.test.ts
+++ b/src/__tests__/spotlight/layersCommands.test.ts
@@ -26,9 +26,11 @@ function loadVisualComponentCanvas(): void {
id: 'vc-card',
name: 'Card',
tree: makeVCTree('vc-root', [
- makeVCNode({ id: 'vc-root', moduleId: 'base.body', children: ['vc-a', 'vc-b'] }),
+ makeVCNode({ id: 'vc-root', moduleId: 'base.body', children: ['vc-a', 'vc-b', 'vc-c', 'vc-d'] }),
makeVCNode({ id: 'vc-a', moduleId: 'base.text', props: { text: 'A' } }),
makeVCNode({ id: 'vc-b', moduleId: 'base.text', props: { text: 'B' } }),
+ makeVCNode({ id: 'vc-c', moduleId: 'base.text', props: { text: 'C' } }),
+ makeVCNode({ id: 'vc-d', moduleId: 'base.text', props: { text: 'D' }, locked: true }),
]),
})
@@ -88,10 +90,29 @@ beforeEach(() => {
describe('Spotlight layer commands', () => {
it('moves selected Visual Component nodes within the active canvas tree', async () => {
await runLayerCommand('layers.moveUp', ['vc-b'])
- expect(vcChildren()).toEqual(['vc-b', 'vc-a'])
+ expect(vcChildren()).toEqual(['vc-b', 'vc-a', 'vc-c', 'vc-d'])
await runLayerCommand('layers.moveDown', ['vc-b'])
- expect(vcChildren()).toEqual(['vc-a', 'vc-b'])
+ expect(vcChildren()).toEqual(['vc-a', 'vc-b', 'vc-c', 'vc-d'])
+ })
+
+ it('moves a multi-selection as one sibling block in one undo step', async () => {
+ await runLayerCommand('layers.moveUp', ['vc-c', 'vc-b'])
+ expect(vcChildren()).toEqual(['vc-b', 'vc-c', 'vc-a', 'vc-d'])
+
+ useEditorStore.getState().undo()
+ expect(vcChildren()).toEqual(['vc-a', 'vc-b', 'vc-c', 'vc-d'])
+
+ await runLayerCommand('layers.moveDown', ['vc-a', 'vc-b'])
+ expect(vcChildren()).toEqual(['vc-c', 'vc-a', 'vc-b', 'vc-d'])
+
+ useEditorStore.getState().undo()
+ expect(vcChildren()).toEqual(['vc-a', 'vc-b', 'vc-c', 'vc-d'])
+ })
+
+ it('does not move a selection that contains a locked layer', async () => {
+ await runLayerCommand('layers.moveUp', ['vc-b', 'vc-d'])
+ expect(vcChildren()).toEqual(['vc-a', 'vc-b', 'vc-c', 'vc-d'])
})
it('navigates parent and child selection inside the active Visual Component tree', async () => {
diff --git a/src/admin/pages/site/preferences/catalog.ts b/src/admin/pages/site/preferences/catalog.ts
index d0dd88682..445abdef1 100644
--- a/src/admin/pages/site/preferences/catalog.ts
+++ b/src/admin/pages/site/preferences/catalog.ts
@@ -47,7 +47,7 @@ const PREFERENCE_CATEGORIES: ReadonlyArray<{
{
id: 'layers',
label: 'Layers panel',
- description: 'Control what is shown next to each row in the DOM tree.',
+ description: 'Control layer-row display and keyboard behavior.',
},
{
id: 'properties',
@@ -247,6 +247,14 @@ export const PREFERENCE_CATALOG = [
description: 'Animate scrolling when the tree jumps to a newly selected layer. Turn off for instant snapping.',
default: true,
},
+ {
+ id: 'layersArrowKeyReorder',
+ type: 'boolean',
+ category: 'layers',
+ label: 'Move layers with Arrow keys',
+ description: 'Use ArrowUp and ArrowDown to move the selected layer within its current parent. Off by default so the keys remain available for navigation.',
+ default: false,
+ },
// ── Properties panel ────────────────────────────────────────────────────
{
diff --git a/src/admin/spotlight/__tests__/shortcutDispatch.test.ts b/src/admin/spotlight/__tests__/shortcutDispatch.test.ts
index b0e875e78..fcb2c2c24 100644
--- a/src/admin/spotlight/__tests__/shortcutDispatch.test.ts
+++ b/src/admin/spotlight/__tests__/shortcutDispatch.test.ts
@@ -1,11 +1,19 @@
-import { describe, expect, it } from 'bun:test'
+import { afterEach, describe, expect, it } from 'bun:test'
import { readFileSync } from 'fs'
import { getKeybindingForCommand } from '../keybindings'
import { findMatchingShortcutCommand } from '../shortcutDispatch'
import type { CommandContext } from '../types'
+import {
+ EDITOR_PREFS_KEY,
+ setEditorPreference,
+} from '@site/preferences/editorPreferences'
const SPOTLIGHT_ROOT = new URL('../SpotlightRoot.tsx', import.meta.url)
+afterEach(() => {
+ localStorage.removeItem(EDITOR_PREFS_KEY)
+})
+
function eventLike(key: string, overrides: Partial = {}) {
return {
key,
@@ -134,6 +142,43 @@ describe('command shortcut dispatch', () => {
expect(binding?.match(eventLike('Backspace', { ctrlKey: true }))).toBe(true)
})
+ it('registers optional ArrowUp/ArrowDown shortcuts for moving layers', () => {
+ const moveUp = getKeybindingForCommand('layers.moveUp')
+ const moveDown = getKeybindingForCommand('layers.moveDown')
+
+ expect(moveUp?.shortcut).toEqual({ mac: '↑', win: '↑' })
+ expect(moveDown?.shortcut).toEqual({ mac: '↓', win: '↓' })
+ expect(moveUp?.match(eventLike('ArrowUp'))).toBe(true)
+ expect(moveDown?.match(eventLike('ArrowDown'))).toBe(true)
+ expect(moveUp?.match(eventLike('ArrowUp', { altKey: true }))).toBe(false)
+ expect(moveDown?.match(eventLike('ArrowDown', { shiftKey: true }))).toBe(false)
+ })
+
+ it('dispatches layer movement only when the Arrow-key preference is enabled', () => {
+ const ctx = context(['site.read', 'site.structure.edit'], {
+ selectedNodeIds: ['node-1'],
+ activePageId: 'page-1',
+ activeDocument: { kind: 'page', pageId: 'page-1' },
+ canUndo: false,
+ canRedo: false,
+ activeBreakpointId: 'desktop',
+ activeInlineEdit: false,
+ })
+ const event = () =>
+ eventLike('ArrowUp', { target: canvasTarget() as EventTarget }) as KeyboardEvent
+
+ expect(findMatchingShortcutCommand(event(), ctx)).toBeNull()
+
+ setEditorPreference('layersArrowKeyReorder', true)
+ expect(findMatchingShortcutCommand(event(), ctx)?.id).toBe('layers.moveUp')
+ expect(
+ findMatchingShortcutCommand(
+ eventLike('ArrowDown', { target: layerTreeTarget() as EventTarget }) as KeyboardEvent,
+ ctx,
+ )?.id,
+ ).toBe('layers.moveDown')
+ })
+
it('matches browser-uppercase canvas clipboard shortcut keys', () => {
const ctx = context(['site.read', 'site.structure.edit'], {
selectedNodeIds: ['node-1'],
diff --git a/src/admin/spotlight/commands/layers.ts b/src/admin/spotlight/commands/layers.ts
index c0be0e832..fe6caa0f8 100644
--- a/src/admin/spotlight/commands/layers.ts
+++ b/src/admin/spotlight/commands/layers.ts
@@ -18,7 +18,7 @@
*/
import { getParent } from '@core/page-tree'
-import type { Command } from '../types'
+import type { Command, CommandRunContext } from '../types'
const hasSelection = (ctx: { editor?: { selectedNodeIds: ReadonlyArray } }) =>
(ctx.editor?.selectedNodeIds.length ?? 0) > 0
@@ -29,6 +29,71 @@ async function getActiveLayerTree() {
return { store, page: selectActiveCanvasPage(store) }
}
+type LayerMoveDirection = 'up' | 'down'
+
+/**
+ * Resolve a one-step sibling move for the current selection.
+ *
+ * Multi-selection is treated as one ordered block. Nested selections collapse
+ * to their selected ancestors, and selections spanning different parents are
+ * left untouched because one sibling move cannot represent that operation.
+ */
+function resolveSelectedSiblingMove(
+ page: NonNullable>['page']>,
+ selectedNodeIds: readonly string[],
+ direction: LayerMoveDirection,
+): { nodeIds: string[]; parentId: string; newIndex: number } | null {
+ if (selectedNodeIds.some((id) => page.nodes[id]?.locked)) return null
+
+ const selected = new Set(selectedNodeIds)
+ const topLevelIds = selectedNodeIds.filter((id) => {
+ if (!page.nodes[id] || id === page.rootNodeId) return false
+ let ancestor = getParent(page, id)
+ while (ancestor) {
+ if (selected.has(ancestor.id)) return false
+ ancestor = getParent(page, ancestor.id)
+ }
+ return true
+ })
+ if (topLevelIds.length === 0) return null
+
+ const parents = topLevelIds.map((id) => getParent(page, id))
+ if (parents.some((parent) => !parent)) return null
+ const parentId = parents[0]!.id
+ if (parents.some((parent) => parent!.id !== parentId)) return null
+
+ const topLevelSet = new Set(topLevelIds)
+ const nodeIds = parents[0]!.children.filter((id) => topLevelSet.has(id))
+ if (nodeIds.length === 0) return null
+
+ const firstIndex = parents[0]!.children.indexOf(nodeIds[0]!)
+ const lastIndex = parents[0]!.children.indexOf(nodeIds[nodeIds.length - 1]!)
+ if (direction === 'up' && firstIndex <= 0) return null
+ if (direction === 'down' && lastIndex >= parents[0]!.children.length - 1) return null
+
+ return {
+ nodeIds,
+ parentId,
+ newIndex: direction === 'up' ? firstIndex - 1 : firstIndex + 1,
+ }
+}
+
+async function moveSelectedLayers(ctx: CommandRunContext, direction: LayerMoveDirection): Promise {
+ ctx.closeSpotlight()
+ const selectedNodeIds = ctx.editor?.selectedNodeIds ?? []
+ if (selectedNodeIds.length === 0) return
+
+ try {
+ const { store, page } = await getActiveLayerTree()
+ if (!page) return
+ const target = resolveSelectedSiblingMove(page, selectedNodeIds, direction)
+ if (!target) return
+ store.moveNodes(target.nodeIds, target.parentId, target.newIndex)
+ } catch (err) {
+ console.error(`[spotlight] moveNode ${direction} failed:`, err)
+ }
+}
+
export function getLayersCommands(): Command[] {
return [
// ── Duplicate layer ──────────────────────────────────────────────────────
@@ -293,23 +358,7 @@ export function getLayersCommands(): Command[] {
workspaces: ['site'],
capability: 'site.structure.edit',
when: hasSelection,
- run: async (ctx) => {
- ctx.closeSpotlight()
- const nodeId = ctx.editor?.selectedNodeIds[ctx.editor.selectedNodeIds.length - 1]
- if (!nodeId) return
- try {
- const { store, page } = await getActiveLayerTree()
- if (!page) return
- const parent = getParent(page, nodeId)
- if (!parent) return
- const siblings = parent.children ?? []
- const idx = siblings.indexOf(nodeId)
- if (idx <= 0) return
- store.moveNode(nodeId, parent.id, idx - 1)
- } catch (err) {
- console.error('[spotlight] moveNode up failed:', err)
- }
- },
+ run: (ctx) => moveSelectedLayers(ctx, 'up'),
},
// ── Move down ────────────────────────────────────────────────────────────
@@ -323,23 +372,7 @@ export function getLayersCommands(): Command[] {
workspaces: ['site'],
capability: 'site.structure.edit',
when: hasSelection,
- run: async (ctx) => {
- ctx.closeSpotlight()
- const nodeId = ctx.editor?.selectedNodeIds[ctx.editor.selectedNodeIds.length - 1]
- if (!nodeId) return
- try {
- const { store, page } = await getActiveLayerTree()
- if (!page) return
- const parent = getParent(page, nodeId)
- if (!parent) return
- const siblings = parent.children ?? []
- const idx = siblings.indexOf(nodeId)
- if (idx < 0 || idx >= siblings.length - 1) return
- store.moveNode(nodeId, parent.id, idx + 1)
- } catch (err) {
- console.error('[spotlight] moveNode down failed:', err)
- }
- },
+ run: (ctx) => moveSelectedLayers(ctx, 'down'),
},
// ── Select parent ────────────────────────────────────────────────────────
diff --git a/src/admin/spotlight/keybindings.ts b/src/admin/spotlight/keybindings.ts
index c941056c3..c374054e5 100644
--- a/src/admin/spotlight/keybindings.ts
+++ b/src/admin/spotlight/keybindings.ts
@@ -28,6 +28,7 @@
* 4. Re-run the architecture test: bun test src/__tests__/architecture/keybindings-registry-single-source.test.ts
*/
+import { readEditorPreferenceBool } from '@site/preferences/editorPreferences'
import type { CommandId, CommandShortcut } from './types'
// ─── Key event shape ──────────────────────────────────────────────────────────
@@ -62,6 +63,8 @@ export interface KeybindingDefinition {
ariaKeyshortcuts?: string
/** Predicate that returns true when the event matches this binding. */
match: (e: KeyEventLike) => boolean
+ /** Optional local preference gate for shortcuts that are opt-in. */
+ enabled?: () => boolean
/** Activation scope — handlers gate firing based on this. */
scope: 'global' | 'editor' | 'canvas' | 'panels'
/**
@@ -204,6 +207,28 @@ export const KEYBINDINGS: ReadonlyArray = [
ignoreInEditableField: true,
},
+ {
+ commandId: 'layers.moveUp',
+ shortcut: { mac: '↑', win: '↑' },
+ ariaKeyshortcuts: 'ArrowUp',
+ match: (e) =>
+ !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && e.key === 'ArrowUp',
+ enabled: () => readEditorPreferenceBool('layersArrowKeyReorder'),
+ scope: 'canvas',
+ ignoreInEditableField: true,
+ },
+
+ {
+ commandId: 'layers.moveDown',
+ shortcut: { mac: '↓', win: '↓' },
+ ariaKeyshortcuts: 'ArrowDown',
+ match: (e) =>
+ !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && e.key === 'ArrowDown',
+ enabled: () => readEditorPreferenceBool('layersArrowKeyReorder'),
+ scope: 'canvas',
+ ignoreInEditableField: true,
+ },
+
{
commandId: 'layers.delete',
shortcut: { mac: '⌘⌫', win: 'Ctrl+Backspace' },
diff --git a/src/admin/spotlight/shortcutDispatch.ts b/src/admin/spotlight/shortcutDispatch.ts
index 5e593290a..137711b39 100644
--- a/src/admin/spotlight/shortcutDispatch.ts
+++ b/src/admin/spotlight/shortcutDispatch.ts
@@ -62,6 +62,7 @@ export function findMatchingShortcutCommand(
for (const binding of KEYBINDINGS) {
if (COMPONENT_OWNED_SHORTCUTS.has(binding.commandId)) continue
+ if (binding.enabled && !binding.enabled()) continue
if (binding.scope === 'canvas' && context.editor?.activeInlineEdit) continue
if (binding.scope === 'canvas' && !isLayerShortcutSurface(event)) continue
if (shouldIgnoreEditableTarget(binding.commandId) && isEditableShortcutTarget(event.target)) continue