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:
parent
f3f70dc070
commit
bc5ebc0028
3 changed files with 452 additions and 5 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue