diff --git a/src/apps/documentation/public/page.js b/src/apps/documentation/public/page.js
index 85050bd..b980afa 100644
--- a/src/apps/documentation/public/page.js
+++ b/src/apps/documentation/public/page.js
@@ -381,6 +381,7 @@ const MODULES_HTML = `
Default Shortcuts
+ Ctrl+Alt+P | Open project switcher popup |
Ctrl+Alt+D | Switch to Dashboard (grid view) in Shell |
Ctrl+Alt+1–9 | Switch to terminal tab 1–9 |
Ctrl+Alt+J | New terminal pane |
diff --git a/src/packages/shared-assets/keymap-registry.js b/src/packages/shared-assets/keymap-registry.js
index 38bfd6f..a648aaa 100644
--- a/src/packages/shared-assets/keymap-registry.js
+++ b/src/packages/shared-assets/keymap-registry.js
@@ -493,6 +493,11 @@
{ ctrlOrMeta: true, altKey: true, code: 'KeyD' },
'Dashboard (grid view)', 'Shell');
+ // Navigation group
+ KeymapRegistry.register('nav:project-switcher',
+ { ctrlOrMeta: true, altKey: true, code: 'KeyP' },
+ 'Project switcher', 'Navigation');
+
// Voice group
KeymapRegistry.register('voice:hold-to-speak',
{ ctrlOrMeta: true, altKey: true, shiftKey: true },
diff --git a/src/public/app.js b/src/public/app.js
index a053688..2a676f7 100644
--- a/src/public/app.js
+++ b/src/public/app.js
@@ -695,6 +695,146 @@ if (typeof VoiceWidget !== 'undefined') {
});
}
+// ── Keyboard project switcher (Ctrl+Alt+P) ──────────────────────────────────
+
+let _projectSwitcherOpen = false;
+
+function openProjectSwitcher() {
+ if (_projectSwitcherOpen) return;
+ _projectSwitcherOpen = true;
+
+ const projects = getProjectList();
+ const active = getActiveProject();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'project-switcher-overlay';
+
+ const popup = document.createElement('div');
+ popup.className = 'project-switcher';
+
+ const header = document.createElement('div');
+ header.className = 'project-switcher-header';
+ header.textContent = 'Switch Project';
+ popup.appendChild(header);
+
+ const list = document.createElement('div');
+ list.className = 'project-switcher-list';
+ list.setAttribute('role', 'listbox');
+
+ if (projects.length === 0) {
+ const empty = document.createElement('div');
+ empty.className = 'project-switcher-empty';
+ empty.textContent = 'No projects registered';
+ list.appendChild(empty);
+ }
+
+ let selectedIdx = projects.findIndex(p => p.id === active?.id);
+ if (selectedIdx < 0) selectedIdx = 0;
+
+ projects.forEach((p, i) => {
+ const item = document.createElement('button');
+ item.className = 'project-switcher-item';
+ if (p.id === active?.id) item.classList.add('active');
+ if (i === selectedIdx) item.classList.add('selected');
+ item.setAttribute('role', 'option');
+ item.dataset.idx = String(i);
+
+ const name = document.createElement('span');
+ name.className = 'project-switcher-item-name';
+ name.textContent = p.name;
+
+ const path = document.createElement('span');
+ path.className = 'project-switcher-item-path';
+ path.textContent = p.path;
+
+ item.appendChild(name);
+ item.appendChild(path);
+ list.appendChild(item);
+
+ item.addEventListener('click', () => {
+ dashboardSocket.emit('project:activate', { id: p.id });
+ closeProjectSwitcher();
+ });
+ });
+
+ popup.appendChild(list);
+ overlay.appendChild(popup);
+ document.body.appendChild(overlay);
+
+ // Focus management
+ requestAnimationFrame(() => {
+ overlay.classList.add('visible');
+ scrollSelectedIntoView(list);
+ });
+
+ function scrollSelectedIntoView(container) {
+ const sel = container.querySelector('.selected');
+ if (sel) sel.scrollIntoView({ block: 'nearest' });
+ }
+
+ function updateSelection(newIdx) {
+ if (projects.length === 0) return;
+ const items = list.querySelectorAll('.project-switcher-item');
+ items[selectedIdx]?.classList.remove('selected');
+ selectedIdx = ((newIdx % projects.length) + projects.length) % projects.length;
+ items[selectedIdx]?.classList.add('selected');
+ scrollSelectedIntoView(list);
+ }
+
+ function onKeyDown(e) {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ closeProjectSwitcher();
+ } else if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ updateSelection(selectedIdx + 1);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ updateSelection(selectedIdx - 1);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (projects.length > 0) {
+ dashboardSocket.emit('project:activate', { id: projects[selectedIdx].id });
+ }
+ closeProjectSwitcher();
+ }
+ }
+
+ document.addEventListener('keydown', onKeyDown, true);
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) closeProjectSwitcher();
+ });
+
+ overlay._cleanup = () => {
+ document.removeEventListener('keydown', onKeyDown, true);
+ };
+}
+
+function closeProjectSwitcher() {
+ if (!_projectSwitcherOpen) return;
+ _projectSwitcherOpen = false;
+ const overlay = document.querySelector('.project-switcher-overlay');
+ if (overlay) {
+ overlay._cleanup?.();
+ overlay.classList.remove('visible');
+ setTimeout(() => overlay.remove(), 200);
+ }
+}
+
+// Global keydown handler for the project switcher hotkey
+document.addEventListener('keydown', (e) => {
+ if (typeof KeymapRegistry === 'undefined') return;
+ if (KeymapRegistry.matchesAction(e, 'nav:project-switcher')) {
+ e.preventDefault();
+ if (_projectSwitcherOpen) {
+ closeProjectSwitcher();
+ } else {
+ openProjectSwitcher();
+ }
+ }
+});
+
// Expose SPA navigate for scenario-runner (spaMode)
window.__devglideSpaNavigate = selectApp;
diff --git a/src/public/style.css b/src/public/style.css
index 69ae966..21a5a84 100644
--- a/src/public/style.css
+++ b/src/public/style.css
@@ -1243,3 +1243,114 @@ a:focus-visible,
border-color: color-mix(in srgb, var(--df-color-state-error) 50%, transparent);
color: var(--df-color-state-error);
}
+
+/* ── Project Switcher Popup ───────────────────────────────────────────────── */
+
+.project-switcher-overlay {
+ position: fixed;
+ inset: 0;
+ background: color-mix(in srgb, var(--df-color-bg-base) 60%, transparent);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+ z-index: var(--df-z-index-modal, 1000);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: 18vh;
+ opacity: 0;
+ transition: opacity var(--df-duration-fast);
+}
+
+.project-switcher-overlay.visible {
+ opacity: 1;
+}
+
+.project-switcher {
+ background: color-mix(in srgb, var(--df-color-bg-surface) 92%, transparent);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid var(--df-color-border-default);
+ border-radius: var(--df-radius-xl);
+ width: 420px;
+ max-width: 90vw;
+ max-height: 50vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5), 0 0 0 1px color-mix(in srgb, var(--df-color-border-strong) 30%, transparent);
+ transform: translateY(-8px);
+ transition: transform var(--df-duration-fast) var(--df-easing-spring);
+}
+
+.project-switcher-overlay.visible .project-switcher {
+ transform: translateY(0);
+}
+
+.project-switcher-header {
+ font-family: var(--df-font-mono);
+ font-size: var(--df-font-size-xs);
+ font-weight: normal;
+ text-transform: uppercase;
+ letter-spacing: var(--df-letter-spacing-wider);
+ color: var(--df-color-text-secondary);
+ padding: var(--df-space-3) var(--df-space-4);
+ border-bottom: 1px solid var(--df-color-border-subtle);
+}
+
+.project-switcher-list {
+ overflow-y: auto;
+ padding: var(--df-space-2);
+}
+
+.project-switcher-empty {
+ font-family: var(--df-font-mono);
+ font-size: var(--df-font-size-sm);
+ color: var(--df-color-text-muted);
+ text-align: center;
+ padding: var(--df-space-6) var(--df-space-4);
+}
+
+.project-switcher-item {
+ display: flex;
+ align-items: baseline;
+ gap: var(--df-space-3);
+ width: 100%;
+ padding: var(--df-space-2) var(--df-space-3);
+ border: 1px solid transparent;
+ border-radius: var(--df-radius-md);
+ background: none;
+ cursor: pointer;
+ font-family: var(--df-font-mono);
+ text-align: left;
+ color: var(--df-color-text-primary);
+ transition: background var(--df-duration-fast), border-color var(--df-duration-fast);
+}
+
+.project-switcher-item.selected {
+ background: color-mix(in srgb, var(--df-color-accent-default) 12%, transparent);
+ border-color: color-mix(in srgb, var(--df-color-accent-default) 30%, transparent);
+}
+
+.project-switcher-item.active .project-switcher-item-name::after {
+ content: ' \2713';
+ color: var(--df-color-accent-default);
+ font-size: var(--df-font-size-xs);
+}
+
+.project-switcher-item:hover {
+ background: var(--df-color-bg-raised);
+}
+
+.project-switcher-item-name {
+ font-size: var(--df-font-size-sm);
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.project-switcher-item-path {
+ font-size: var(--df-font-size-xs);
+ color: var(--df-color-text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}