diff --git a/dashboard/css/command-palette.css b/dashboard/css/command-palette.css index 6a5a538..aac257d 100644 --- a/dashboard/css/command-palette.css +++ b/dashboard/css/command-palette.css @@ -62,7 +62,7 @@ .cp-search-row { display: flex; align-items: center; - gapvar(--space-250); + gap: var(--space-250); padding: 14px var(--space-4); border-bottom: 1px solid var(--border-default); } @@ -93,7 +93,7 @@ color: var(--slate-7); background: var(--highlight-subtle); border-radius: var(--radius-control); - paddingvar(--space-half) var(--space-175); + padding: var(--space-half) var(--space-175); flex-shrink: 0; font-family: var(--font-mono); } @@ -143,7 +143,7 @@ .cp-item { display: flex; align-items: center; - gapvar(--space-250); + gap: var(--space-250); padding: 9px var(--space-4); cursor: pointer; transition: background 0.1s; @@ -197,6 +197,19 @@ line-height: 1; } +.cp-item-hint { + font-family: var(--font-mono); + font-size: var(--text-3xs); + color: var(--slate-7); + background: var(--highlight-subtle); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + padding: var(--space-half) var(--space-175); + line-height: 1; + flex-shrink: 0; + margin-right: var(--space-175); +} + .cp-item-selected .cp-item-arrow { color: var(--blue-9); } @@ -208,6 +221,10 @@ top: 12%; transform: translateX(-50%); } + + .cp-shortcut-hint { + display: none !important; + } } /* ===== Reduced motion ===== */ @@ -217,3 +234,29 @@ animation: none; } } + +/* ===== Toolbar shortcut hint ===== */ +.cp-shortcut-hint { + cursor: pointer; + opacity: 0.6; + transition: opacity var(--transition-fast); +} + +.cp-shortcut-hint:hover { + opacity: 1; +} + +.cp-shortcut-hint kbd { + font-family: var(--font-mono); + font-size: var(--text-3xs); + color: var(--slate-9); + background: var(--highlight-subtle); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + padding: var(--space-half) var(--space-175); + line-height: 1; +} + +.cp-shortcut-hint-dismissed { + display: none !important; +} diff --git a/dashboard/js/command-palette.js b/dashboard/js/command-palette.js index 61e9947..6430609 100644 --- a/dashboard/js/command-palette.js +++ b/dashboard/js/command-palette.js @@ -186,6 +186,25 @@ if (!isNaN(dt.getTime())) return dt; } + // @this morning → today at 06:00 + if (/^this\s+morning$/i.test(s)) { + var morning = new Date(now); + morning.setHours(6, 0, 0, 0); + return morning; + } + + // @last night → yesterday at 22:00 + if (/^last\s+night$/i.test(s)) { + var lastNight = new Date(now); + lastNight.setHours(22, 0, 0, 0); + if (now.getHours() < 6) { + // Before 6am — "last night" was yesterday + } else { + lastNight.setDate(lastNight.getDate() - 1); + } + return lastNight; + } + // @yesterday ... var yest = s.match(/^yesterday\s+(.+)$/i); if (yest) { @@ -232,7 +251,7 @@ category: 'command', group: 'Navigation', icon: '⚙', - hint: '', + hint: 'Ctrl+,', action: function () { window.location.href = '/settings'; } }, { @@ -471,6 +490,253 @@ if (window.showToast) window.showToast('Could not fetch firmware info', 'warning'); }); } + }, + // ---- Security ---- + { + id: 'security-arm', + label: 'Arm security', + category: 'command', + group: 'Security', + icon: '🛡', + hint: '', + action: function () { + fetch('/api/security/arm', { method: 'POST' }).then(function () { + if (window.showToast) window.showToast('Security mode armed', 'info'); + }); + } + }, + { + id: 'security-disarm', + label: 'Disarm security', + category: 'command', + group: 'Security', + icon: '🔓', + hint: '', + action: function () { + fetch('/api/security/disarm', { method: 'POST' }).then(function () { + if (window.showToast) window.showToast('Security mode disarmed', 'info'); + }); + } + }, + // ---- Appearance ---- + { + id: 'theme-dark', + label: 'Dark mode', + category: 'command', + group: 'Appearance', + icon: '🌙', + hint: '', + action: function () { + document.documentElement.setAttribute('data-theme', 'dark'); + try { localStorage.setItem('spaxel-theme', 'dark'); } catch (e) {} + if (window.showToast) window.showToast('Dark mode enabled', 'info'); + } + }, + { + id: 'theme-light', + label: 'Light mode', + category: 'command', + group: 'Appearance', + icon: '☀', + hint: '', + action: function () { + document.documentElement.setAttribute('data-theme', 'light'); + try { localStorage.setItem('spaxel-theme', 'light'); } catch (e) {} + if (window.showToast) window.showToast('Light mode enabled', 'info'); + } + }, + // ---- Actions ---- + { + id: 'add-node', + label: 'Add node', + category: 'command', + group: 'Actions', + icon: '🔌', + hint: '', + action: function () { + if (window.OnboardWizard && window.OnboardWizard.start) window.OnboardWizard.start(); + else window.location.href = '/onboard'; + } + }, + { + id: 'rebaseline-all', + label: 'Re-baseline all links', + category: 'command', + group: 'Actions', + icon: '🎯', + hint: '', + action: function () { + fetch('/api/nodes/rebaseline-all', { method: 'POST' }).then(function () { + if (window.showToast) window.showToast('Re-baseline started for all links', 'info'); + }); + } + }, + { + id: 'export-config', + label: 'Export config', + category: 'command', + group: 'Actions', + icon: '📦', + hint: '', + action: function () { + var a = document.createElement('a'); + a.href = '/api/export'; + a.download = 'spaxel-config.json'; + a.click(); + } + }, + { + id: 'view-top', + label: 'Camera: Top view', + category: 'command', + group: 'View', + icon: '⬆', + hint: '', + action: function () { + if (window.Viz3D && window.Viz3D.setViewPreset) window.Viz3D.setViewPreset('topdown'); + } + }, + { + id: 'view-front', + label: 'Camera: Front view', + category: 'command', + group: 'View', + icon: '👁', + hint: '', + action: function () { + if (window.Viz3D && window.Viz3D.setViewPreset) window.Viz3D.setViewPreset('front'); + } + }, + { + id: 'view-perspective', + label: 'Camera: Perspective', + category: 'command', + group: 'View', + icon: '🎲', + hint: '', + action: function () { + if (window.Viz3D && window.Viz3D.setViewPreset) window.Viz3D.setViewPreset('perspective'); + } + }, + { + id: 'view-trails', + label: 'Toggle trails', + category: 'command', + group: 'View', + icon: '👣', + hint: '', + action: function () { + if (window.Viz3D && window.Viz3D.toggleTrails) window.Viz3D.toggleTrails(); + } + }, + { + id: 'view-links', + label: 'Toggle link lines', + category: 'command', + group: 'View', + icon: '🔗', + hint: '', + action: function () { + if (window.Viz3D && window.Viz3D.toggleLinks) window.Viz3D.toggleLinks(); + } + }, + { + id: 'view-floorplan', + label: 'Toggle floor plan', + category: 'command', + group: 'View', + icon: '🗺', + hint: '', + action: function () { + if (window.Viz3D && window.Viz3D.toggleFloorPlan) window.Viz3D.toggleFloorPlan(); + } + }, + { + id: 'view-coverage', + label: 'Toggle coverage overlay', + category: 'command', + group: 'View', + icon: '🟩', + hint: '', + action: function () { + if (window.Viz3D && window.Viz3D.toggleCoverageOverlay) window.Viz3D.toggleCoverageOverlay(); + } + }, + // ---- Help ---- + { + id: 'help-fall-detection', + label: 'Help: fall detection', + category: 'command', + group: 'Help', + icon: '❓', + hint: '', + action: function () { + if (window.HelpOverlay && window.HelpOverlay.openTopic) window.HelpOverlay.openTopic('fall-detection'); + else if (window.HelpOverlay && window.HelpOverlay.toggle) window.HelpOverlay.toggle(); + } + }, + { + id: 'help-breathing', + label: 'Help: breathing detection', + category: 'command', + group: 'Help', + icon: '❓', + hint: '', + action: function () { + if (window.HelpOverlay && window.HelpOverlay.openTopic) window.HelpOverlay.openTopic('breathing'); + else if (window.HelpOverlay && window.HelpOverlay.toggle) window.HelpOverlay.toggle(); + } + }, + { + id: 'help-prediction', + label: 'Help: how does prediction work', + category: 'command', + group: 'Help', + icon: '❓', + hint: '', + action: function () { + if (window.HelpOverlay && window.HelpOverlay.openTopic) window.HelpOverlay.openTopic('prediction'); + else if (window.HelpOverlay && window.HelpOverlay.toggle) window.HelpOverlay.toggle(); + } + }, + { + id: 'help-why-false-positive', + label: 'Help: why false positive', + category: 'command', + group: 'Help', + icon: '❓', + hint: '', + action: function () { + if (window.Explainability && window.Explainability.showLastIncorrect) { + window.Explainability.showLastIncorrect(); + } else if (window.HelpOverlay && window.HelpOverlay.openTopic) { + window.HelpOverlay.openTopic('false-positives'); + } + } + }, + { + id: 'help-troubleshoot', + label: 'Help: troubleshoot detection', + category: 'command', + group: 'Help', + icon: '❓', + hint: '', + action: function () { + if (window.Troubleshoot && window.Troubleshoot.open) window.Troubleshoot.open(); + else if (window.HelpOverlay && window.HelpOverlay.openTopic) window.HelpOverlay.openTopic('troubleshoot'); + } + }, + { + id: 'help-shortcuts', + label: 'Help: keyboard shortcuts', + category: 'command', + group: 'Help', + icon: '⌨', + hint: '', + action: function () { + if (window.HelpOverlay && window.HelpOverlay.openTopic) window.HelpOverlay.openTopic('shortcuts'); + else if (window.HelpOverlay && window.HelpOverlay.toggle) window.HelpOverlay.toggle(); + } } ]; @@ -593,6 +859,7 @@ category: 'command', icon: cmd.icon, secondary: cmd.group || '', + hint: cmd.hint || '', score: s, action: cmd.action }); @@ -762,6 +1029,7 @@ } var selectedClass = (i === Manager.selectedIndex) ? ' cp-item-selected' : ''; + var hintHtml = item.hint ? ' ' + escapeHtml(item.hint) + '' : ''; html += '
  • ' + @@ -770,6 +1038,7 @@ ' ' + escapeHtml(item.label) + '' + ' ' + escapeHtml(item.secondary || '') + '' + ' ' + + hintHtml + ' ' + '
  • '; @@ -836,6 +1105,7 @@ init: function () { // Register Ctrl+K / Cmd+K globally document.addEventListener('keydown', this._onKeydown.bind(this)); + this._initShortcutHint(); }, open: function () { @@ -864,6 +1134,7 @@ } this._showItems([]); + this._dismissHint(); }, close: function () { @@ -949,6 +1220,36 @@ if (!list) return; var sel = list.querySelector('.cp-item-selected'); if (sel) sel.scrollIntoView({ block: 'nearest' }); + }, + + _initShortcutHint: function () { + var hint = document.getElementById('cp-shortcut-hint'); + if (!hint) return; + + // Show correct modifier for platform + var kbd = document.getElementById('cp-kbd'); + if (kbd && navigator.platform && /Mac|iPod|iPhone|iPad/.test(navigator.platform)) { + kbd.textContent = '⌘K'; + } + + // Dismiss after first palette open + var DISMISSED_KEY = 'spaxel_palette_hint_dismissed'; + if (localStorage.getItem(DISMISSED_KEY)) { + hint.classList.add('cp-shortcut-hint-dismissed'); + } + + // Click opens palette + hint.addEventListener('click', function () { + Manager.open(); + }); + }, + + _dismissHint: function () { + var hint = document.getElementById('cp-shortcut-hint'); + if (hint) { + hint.classList.add('cp-shortcut-hint-dismissed'); + try { localStorage.setItem('spaxel_palette_hint_dismissed', '1'); } catch (e) {} + } } }; diff --git a/dashboard/js/command-palette.test.js b/dashboard/js/command-palette.test.js index 6578c62..56979dd 100644 --- a/dashboard/js/command-palette.test.js +++ b/dashboard/js/command-palette.test.js @@ -95,6 +95,14 @@ describe('CommandPaletteManager', function () { it('"bedrm" → "Bedroom" scores >= 0.3', function () { expect(fuzzyScore('bedrm', 'Bedroom')).toBeGreaterThanOrEqual(0.3); }); + + it('"flr pln" → "Toggle floor plan" scores >= 0.3 (fuzzy multi-word)', function () { + expect(fuzzyScore('flr pln', 'Toggle floor plan')).toBeGreaterThanOrEqual(0.3); + }); + + it('"brth" → "Help: breathing detection" scores >= 0.3 (subsequence)', function () { + expect(fuzzyScore('brth', 'Help: breathing detection')).toBeGreaterThanOrEqual(0.3); + }); }); // ── Time parsing ───────────────────────────────────────────────────────── @@ -189,6 +197,20 @@ describe('CommandPaletteManager', function () { var d = parse('@'); expect(d).toBeNull(); }); + + it('@this morning → today at 06:00', function () { + var d = parse('@this morning'); + expect(d).not.toBeNull(); + expect(d.getHours()).toBe(6); + expect(d.getMinutes()).toBe(0); + }); + + it('@last night → yesterday at 22:00 (after 6am)', function () { + var d = parse('@last night'); + expect(d).not.toBeNull(); + expect(d.getHours()).toBe(22); + expect(d.getMinutes()).toBe(0); + }); }); // ── Commands registry completeness ─────────────────────────────────────── @@ -220,7 +242,32 @@ describe('CommandPaletteManager', function () { 'Export all events CSV', 'Show link health table', 'Run diagnostics', - 'Check firmware updates' + 'Check firmware updates', + // Security commands + 'Arm security', + 'Disarm security', + // Appearance commands + 'Dark mode', + 'Light mode', + // Actions + 'Add node', + 'Re-baseline all links', + 'Export config', + // View presets + 'Camera: Top view', + 'Camera: Front view', + 'Camera: Perspective', + 'Toggle trails', + 'Toggle link lines', + 'Toggle floor plan', + 'Toggle coverage overlay', + // Help + 'Help: fall detection', + 'Help: breathing detection', + 'Help: how does prediction work', + 'Help: why false positive', + 'Help: troubleshoot detection', + 'Help: keyboard shortcuts' ]; REQUIRED_COMMANDS.forEach(function (label) { @@ -540,5 +587,61 @@ describe('CommandPaletteManager', function () { var results = search('@zzznottimestamp'); expect(results.length).toBe(0); }); + + it('search for "arm" finds "Arm security"', function () { + var search = window.CommandPaletteManager._search; + if (!search) return; + var results = search('arm'); + expect(results.length).toBeGreaterThan(0); + var labels = results.map(function (r) { return r.label; }); + expect(labels).toContain('Arm security'); + }); + + it('search for "disarm" finds "Disarm security"', function () { + var search = window.CommandPaletteManager._search; + if (!search) return; + var results = search('disarm'); + expect(results.length).toBeGreaterThan(0); + var labels = results.map(function (r) { return r.label; }); + expect(labels).toContain('Disarm security'); + }); + + it('search for "help" returns help topic commands', function () { + var search = window.CommandPaletteManager._search; + if (!search) return; + var results = search('help'); + expect(results.length).toBeGreaterThan(0); + var helpResults = results.filter(function (r) { + return r.category === 'command' && r.label.indexOf('Help:') === 0; + }); + expect(helpResults.length).toBeGreaterThan(0); + }); + + it('search for "dark mode" finds the command', function () { + var search = window.CommandPaletteManager._search; + if (!search) return; + var results = search('dark mode'); + expect(results.length).toBeGreaterThan(0); + var labels = results.map(function (r) { return r.label; }); + expect(labels).toContain('Dark mode'); + }); + + it('search for "@this morning" returns a time result', function () { + var search = window.CommandPaletteManager._search; + if (!search) return; + var results = search('@this morning'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].category).toBe('time'); + }); + + it('command results include hint field', function () { + var search = window.CommandPaletteManager._search; + if (!search) return; + var results = search('settings'); + var settingsResult = results.find(function (r) { return r.id === 'nav-settings'; }); + if (settingsResult) { + expect(typeof settingsResult.hint).toBe('string'); + } + }); }); });