feat(dashboard): implement command palette with fuzzy search and time navigation

Ctrl+K / Cmd+K universal search interface for expert mode with:
- Fuzzy matching (prefix, substring, word-level, subsequence)
- Time navigation via @ prefix (@3am, @yesterday 11pm, @this morning, @last night)
- 36 commands across navigation, view, system, security, appearance, actions, help
- Entity search across zones, people, nodes, events
- Recent history with localStorage persistence
- Expert-mode gating (disabled in simple/ambient modes)
- Keyboard navigation (arrow keys, Enter, Escape, Tab)
- Toolbar shortcut hint with platform-aware display (⌘K on Mac)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-24 17:36:14 -04:00
parent f3f70dc070
commit bc5ebc0028
3 changed files with 452 additions and 5 deletions

View file

@ -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;
}

View file

@ -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 ? ' <kbd class="cp-item-hint">' + escapeHtml(item.hint) + '</kbd>' : '';
html +=
'<li class="cp-item' + selectedClass + '" data-index="' + i + '" role="option"' +
' aria-selected="' + (i === Manager.selectedIndex) + '">' +
@ -770,6 +1038,7 @@
' <span class="cp-item-label">' + escapeHtml(item.label) + '</span>' +
' <span class="cp-item-secondary">' + escapeHtml(item.secondary || '') + '</span>' +
' </span>' +
hintHtml +
' <span class="cp-item-arrow"></span>' +
'</li>';
@ -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) {}
}
}
};

View file

@ -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');
}
});
});
});