';
- });
- if (otherDevices.length > 10) {
- html += '
+ ' + (otherDevices.length - 10) + ' more
';
- }
- devicesList.innerHTML = html;
-
- // Add click handlers
- devicesList.querySelectorAll('.device-edit').forEach(function(btn) {
- btn.addEventListener('click', function(e) {
- e.stopPropagation();
- var addr = this.getAttribute('data-addr');
- showEditModal(addr);
- });
- });
- }
- }
-
- // Toggle device expanded state
- function toggleDeviceExpanded(addr) {
- var device = state.devices.get(addr);
- if (device) {
- device.expanded = !device.expanded;
- updateDeviceList();
- }
- }
-
- // Show add person modal
- function showAddPersonModal() {
- state.editingDevice = null;
- document.getElementById('modal-title').textContent = 'Add Person';
- document.getElementById('modal-name').value = '';
- document.getElementById('modal-label').value = '';
- document.getElementById('modal-color').value = '#4fc3f7';
- document.getElementById('modal-is-person').checked = true;
- document.getElementById('modal-device-type').value = 'phone';
- document.getElementById('ble-device-modal').style.display = 'flex';
- }
-
- // Show edit modal
- function showEditModal(addr) {
- var device = state.devices.get(addr);
- if (!device) return;
-
- state.editingDevice = addr;
- document.getElementById('modal-title').textContent = 'Edit Device';
- document.getElementById('modal-name').value = device.name || '';
- document.getElementById('modal-label').value = device.label || '';
- document.getElementById('modal-color').value = device.color || '#4fc3f7';
- document.getElementById('modal-is-person').checked = device.is_person;
- document.getElementById('modal-device-type').value = device.device_type || 'unknown';
- document.getElementById('ble-device-modal').style.display = 'flex';
- }
-
- // Hide modal
- function hideModal() {
- document.getElementById('ble-device-modal').style.display = 'none';
- state.editingDevice = null;
- }
-
- // Save device
- function saveDevice() {
- var data = {
- name: document.getElementById('modal-name').value,
- label: document.getElementById('modal-label').value,
- color: document.getElementById('modal-color').value,
- is_person: document.getElementById('modal-is-person').checked,
- device_type: document.getElementById('modal-device-type').value,
- enabled: true
- };
-
- var addr = state.editingDevice || 'new-' + Date.now();
- var url = '/api/ble/devices/' + encodeURIComponent(addr);
- var method = state.editingDevice ? 'PUT' : 'POST';
-
- fetch(url, {
- method: method,
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
- })
- .then(function(res) {
- if (res.ok) {
- hideModal();
- fetchDevices();
- } else {
- return res.json().then(function(err) {
- throw new Error(err.error || 'Failed to save');
- });
- }
- })
- .catch(function(err) {
- alert('Failed to save device: ' + err.message);
- });
- }
-
- // Show merge confirmation modal
- function showMergeConfirm(mac1, mac2) {
- state.pendingMerge = { mac1: mac1, mac2: mac2 };
-
- var device1 = state.devices.get(mac1);
- var device2 = state.devices.get(mac2);
-
- document.getElementById('merge-device-1').querySelector('.merge-mac').textContent = mac1;
- document.getElementById('merge-device-1').querySelector('.merge-name').textContent =
- device1 ? (device1.name || device1.device_name || 'Unknown') : 'Unknown';
- document.getElementById('merge-device-2').querySelector('.merge-mac').textContent = mac2;
- document.getElementById('merge-device-2').querySelector('.merge-name').textContent =
- device2 ? (device2.name || device2.device_name || 'Unknown') : 'Unknown';
-
- document.getElementById('ble-merge-modal').style.display = 'flex';
- }
-
- // Hide merge modal
- function hideMergeModal() {
- document.getElementById('ble-merge-modal').style.display = 'none';
- state.pendingMerge = null;
- }
-
- // Confirm and execute merge
- function confirmMerge() {
- if (!state.pendingMerge) {
- return;
- }
-
- var mac1 = state.pendingMerge.mac1;
- var mac2 = state.pendingMerge.mac2;
-
- fetch('/api/ble/merge', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ mac1: mac1, mac2: mac2 })
- })
- .then(function(res) {
- if (res.ok) {
- return res.json();
- } else {
- return res.json().then(function(err) {
- throw new Error(err.error || 'Failed to merge');
- });
- }
- })
- .then(function(data) {
- hideMergeModal();
- // Remove from duplicates list
- state.duplicates = state.duplicates.filter(function(d) {
- return d.mac1 !== mac1 || d.mac2 !== mac2;
- });
- updateDuplicatesList();
- fetchDevices();
- })
- .catch(function(err) {
- alert('Failed to merge devices: ' + err.message);
- });
- }
-
- // Get icon for device type
- function getTypeIcon(type) {
- switch (type) {
- case 'phone': return '📱';
- case 'watch': return '⌚';
- case 'tracker': return '📍';
- case 'tablet': return '📱';
- case 'laptop': return '💻';
- case 'headphones': return '🎧';
- default: return '📡';
- }
- }
-
- // Format time relative to now
- function formatTime(date) {
- var now = new Date();
- var diff = (now - date) / 1000;
-
- if (diff < 60) return 'just now';
- if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
- if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
- return date.toLocaleDateString();
- }
-
- // Export public interface
- window.BLEPanel = {
- init: init,
- updateMatches: handleMatchesUpdate,
- updateDevices: handleDevicesUpdate
- };
-
-})();
diff --git a/dashboard/js/commandpalette.test.js b/dashboard/js/command-palette.test.js
similarity index 99%
rename from dashboard/js/commandpalette.test.js
rename to dashboard/js/command-palette.test.js
index b92e7b8..6578c62 100644
--- a/dashboard/js/commandpalette.test.js
+++ b/dashboard/js/command-palette.test.js
@@ -32,7 +32,7 @@ describe('CommandPaletteManager', function () {
// Load the module (if not already loaded in this env)
if (typeof window.CommandPaletteManager === 'undefined') {
- require('./commandpalette.js');
+ require('./command-palette.js');
}
});
diff --git a/dashboard/js/commandpalette.js b/dashboard/js/commandpalette.js
deleted file mode 100644
index 61e9947..0000000
--- a/dashboard/js/commandpalette.js
+++ /dev/null
@@ -1,978 +0,0 @@
-/**
- * Spaxel Dashboard — Command Palette (Component 34)
- *
- * Ctrl+K / Cmd+K: universal keyboard-driven interface for expert mode.
- * Fuzzy search across zones, people, nodes, events, and commands.
- * Time navigation via "@" prefix.
- *
- * Exposes: window.CommandPaletteManager
- */
-
-(function () {
- 'use strict';
-
- // =========================================================
- // Constants
- // =========================================================
- var STORAGE_KEY = 'spaxel_palette_history';
- var MAX_RECENT = 5;
- var MAX_RESULTS = 8;
-
- // Category priority (lower = higher in results)
- var CAT_PRIORITY = {
- command: 0,
- time: 1,
- person: 2,
- zone: 3,
- node: 4,
- event: 5,
- recent: -1 // shown only on empty query
- };
-
- // =========================================================
- // Levenshtein distance (compact)
- // =========================================================
- function levenshteinDist(a, b) {
- var m = a.length, n = b.length;
- if (!m) return n;
- if (!n) return m;
- var prev = [], curr = [];
- for (var j = 0; j <= n; j++) prev[j] = j;
- for (var i = 1; i <= m; i++) {
- curr[0] = i;
- for (var k = 1; k <= n; k++) {
- curr[k] = a[i - 1] === b[k - 1]
- ? prev[k - 1]
- : 1 + Math.min(prev[k], curr[k - 1], prev[k - 1]);
- }
- var tmp = prev; prev = curr; curr = tmp;
- }
- return prev[n];
- }
-
- // =========================================================
- // Fuzzy scorer [0, 1]
- // =========================================================
- /**
- * Returns a score in [0, 1] indicating how well `needle` matches `haystack`.
- * Scores below 0.3 are considered non-matches and are excluded from results.
- *
- * Matching strategy (in priority order):
- * 1. Exact prefix of full haystack → 0.90–1.00
- * 2. Exact substring of full haystack → 0.80
- * 3. Word-level matching (prefix / typo / subseq per word)
- * 4. Character subsequence across full string → 0.30–0.40
- */
- function fuzzyScore(needle, haystack) {
- if (!needle) return 1;
- needle = needle.toLowerCase().trim();
- haystack = haystack.toLowerCase().trim();
- if (!needle) return 1;
- if (needle === haystack) return 1;
-
- // 1. Full prefix
- if (haystack.startsWith(needle)) {
- return 0.90 + 0.10 * (needle.length / haystack.length);
- }
-
- // 2. Exact substring
- if (haystack.includes(needle)) {
- return 0.80;
- }
-
- // 3. Word-level matching
- var needleWords = needle.split(/\s+/).filter(function (w) { return w.length > 0; });
- var haystackWords = haystack.split(/\s+/).filter(function (w) { return w.length > 0; });
-
- if (needleWords.length > 0) {
- var allMatch = true;
- var totalScore = 0;
-
- for (var ni = 0; ni < needleWords.length; ni++) {
- var nw = needleWords[ni];
- var bestWord = 0;
-
- for (var hi = 0; hi < haystackWords.length; hi++) {
- var hw = haystackWords[hi];
- var ws = 0;
-
- if (hw.startsWith(nw)) {
- ws = 0.90;
- } else if (hw.includes(nw)) {
- ws = 0.75;
- } else if (nw.length > 2 && hw.length > 2) {
- var dist = levenshteinDist(nw, hw);
- if (dist === 1) {
- ws = 0.70;
- } else if (dist === 2 && nw.length > 4) {
- ws = 0.50;
- }
- // Per-word subsequence (e.g. "rm" in "room")
- if (ws === 0) {
- var si = 0;
- for (var ci = 0; ci < hw.length && si < nw.length; ci++) {
- if (nw[si] === hw[ci]) si++;
- }
- if (si === nw.length) ws = 0.50;
- }
- } else if (nw.length <= 2) {
- // Short needle: prefix or subsequence within each haystack word
- if (hw.startsWith(nw)) {
- ws = 0.75;
- } else {
- var si2 = 0;
- for (var ci2 = 0; ci2 < hw.length && si2 < nw.length; ci2++) {
- if (nw[si2] === hw[ci2]) si2++;
- }
- if (si2 === nw.length) ws = 0.50;
- }
- }
-
- if (ws > bestWord) bestWord = ws;
- }
-
- if (bestWord === 0) {
- allMatch = false;
- break;
- }
- totalScore += bestWord;
- }
-
- if (allMatch) {
- return 0.40 + (totalScore / needleWords.length) * 0.30;
- }
- }
-
- // 4. Character subsequence across full string
- var si3 = 0;
- for (var ci3 = 0; ci3 < haystack.length && si3 < needle.length; ci3++) {
- if (needle[si3] === haystack[ci3]) si3++;
- }
- if (si3 === needle.length) {
- return 0.30 + 0.10 * (needle.length / haystack.length);
- }
-
- return 0;
- }
-
- // =========================================================
- // Time expression parser
- // =========================================================
- /**
- * Parse a "@..." time expression.
- * @param {string} query - full query string starting with "@"
- * @returns {Date|null}
- */
- function parseTimeExpression(query) {
- var s = query.replace(/^@/, '').trim();
- if (!s) return null;
- var now = new Date();
-
- // @-30min @-2h
- var rel = s.match(/^-(\d+)(min|h)$/i);
- if (rel) {
- var amount = parseInt(rel[1], 10);
- var unit = rel[2].toLowerCase();
- var d = new Date(now);
- if (unit === 'min') d.setMinutes(d.getMinutes() - amount);
- else d.setHours(d.getHours() - amount);
- return d;
- }
-
- // @2026-03-27 14:23
- var abs = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{1,2}:\d{2})$/);
- if (abs) {
- var dt = new Date(abs[1] + 'T' + abs[2] + ':00');
- if (!isNaN(dt.getTime())) return dt;
- }
-
- // @yesterday ...
- var yest = s.match(/^yesterday\s+(.+)$/i);
- if (yest) {
- var base = new Date(now);
- base.setDate(base.getDate() - 1);
- return parseTimeOfDay(yest[1], base);
- }
-
- // @3am @3:15pm @14:23
- return parseTimeOfDay(s, new Date(now));
- }
-
- function parseTimeOfDay(s, baseDate) {
- // 12-hour: 3am, 3:15am, 11:30pm
- var m12 = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i);
- if (m12) {
- var h = parseInt(m12[1], 10);
- var min = m12[2] ? parseInt(m12[2], 10) : 0;
- var ampm = m12[3].toLowerCase();
- if (ampm === 'pm' && h !== 12) h += 12;
- if (ampm === 'am' && h === 12) h = 0;
- var r = new Date(baseDate);
- r.setHours(h, min, 0, 0);
- return r;
- }
- // 24-hour: 14:23
- var m24 = s.match(/^(\d{1,2}):(\d{2})$/);
- if (m24) {
- var r2 = new Date(baseDate);
- r2.setHours(parseInt(m24[1], 10), parseInt(m24[2], 10), 0, 0);
- return r2;
- }
- return null;
- }
-
- // =========================================================
- // Command registry
- // =========================================================
- var COMMANDS = [
- // ---- Navigation ----
- {
- id: 'nav-settings',
- label: 'Open settings',
- category: 'command',
- group: 'Navigation',
- icon: '⚙',
- hint: '',
- action: function () { window.location.href = '/settings'; }
- },
- {
- id: 'nav-fleet',
- label: 'Open fleet page',
- category: 'command',
- group: 'Navigation',
- icon: '📡',
- hint: '',
- action: function () { window.location.href = '/fleet'; }
- },
- {
- id: 'nav-automations',
- label: 'Open automations',
- category: 'command',
- group: 'Navigation',
- icon: '⚡',
- hint: '',
- action: function () { window.location.href = '/automations'; }
- },
- {
- id: 'nav-simulator',
- label: 'Open simulator',
- category: 'command',
- group: 'Navigation',
- icon: '🔬',
- hint: '',
- action: function () { window.location.href = '/simulate'; }
- },
- // ---- View ----
- {
- id: 'view-fresnel',
- label: 'Toggle Fresnel overlay',
- category: 'command',
- group: 'View',
- icon: '◈',
- hint: '',
- action: function () {
- if (window.toggleFresnelZones) window.toggleFresnelZones();
- }
- },
- {
- id: 'view-flowmap',
- label: 'Toggle flow map',
- category: 'command',
- group: 'View',
- icon: '🌊',
- hint: '',
- action: function () {
- if (window.Viz3D && window.Viz3D.toggleFlowLayer) window.Viz3D.toggleFlowLayer();
- }
- },
- {
- id: 'view-heatmap',
- label: 'Toggle dwell heatmap',
- category: 'command',
- group: 'View',
- icon: '🔥',
- hint: '',
- action: function () {
- if (window.Viz3D && window.Viz3D.toggleDwellLayer) window.Viz3D.toggleDwellLayer();
- }
- },
- {
- id: 'view-zones',
- label: 'Toggle zone volumes',
- category: 'command',
- group: 'View',
- icon: '📦',
- hint: '',
- action: function () {
- if (window.ZoneEditor && window.ZoneEditor.toggleVolumes) window.ZoneEditor.toggleVolumes();
- else if (window.Viz3D && window.Viz3D.toggleZoneVolumes) window.Viz3D.toggleZoneVolumes();
- }
- },
- {
- id: 'view-reset-camera',
- label: 'Reset camera',
- category: 'command',
- group: 'View',
- icon: '🎥',
- hint: '',
- action: function () {
- if (window.Viz3D && window.Viz3D.setViewPreset) window.Viz3D.setViewPreset('topdown');
- }
- },
- // ---- System ----
- {
- id: 'mode-away',
- label: 'Enter away mode',
- category: 'command',
- group: 'System',
- icon: '🏠',
- hint: '',
- action: function () {
- fetch('/api/mode', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ mode: 'away' })
- });
- }
- },
- {
- id: 'mode-home',
- label: 'Enter home mode',
- category: 'command',
- group: 'System',
- icon: '🏡',
- hint: '',
- action: function () {
- fetch('/api/mode', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ mode: 'home' })
- });
- }
- },
- {
- id: 'mode-sleep',
- label: 'Enter sleep mode',
- category: 'command',
- group: 'System',
- icon: '🌙',
- hint: '',
- action: function () {
- fetch('/api/mode', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ mode: 'sleep' })
- });
- }
- },
- {
- id: 'ota-fleet',
- label: 'Trigger fleet OTA',
- category: 'command',
- group: 'System',
- icon: '⬆',
- hint: '',
- action: function () {
- if (window.SpaxelOTA && window.SpaxelOTA.openDialog) {
- window.SpaxelOTA.openDialog();
- } else {
- fetch('/api/nodes/update-all', { method: 'POST' });
- }
- }
- },
- {
- id: 'add-person',
- label: 'Add a person',
- category: 'command',
- group: 'System',
- icon: '👤',
- hint: '',
- action: function () {
- if (window.BLEPanel && window.BLEPanel.openAddPerson) window.BLEPanel.openAddPerson();
- }
- },
- {
- id: 'add-zone',
- label: 'Add a zone',
- category: 'command',
- group: 'System',
- icon: '📍',
- hint: '',
- action: function () {
- if (window.ZoneEditor && window.ZoneEditor.startCreate) window.ZoneEditor.startCreate();
- }
- },
- {
- id: 'add-portal',
- label: 'Add a portal',
- category: 'command',
- group: 'System',
- icon: '🚪',
- hint: '',
- action: function () {
- if (window.PortalEditor && window.PortalEditor.startCreate) window.PortalEditor.startCreate();
- }
- },
- // ---- Debug ----
- {
- id: 'debug-export-csv',
- label: 'Export all events CSV',
- category: 'command',
- group: 'Debug',
- icon: '📥',
- hint: '',
- action: function () {
- var a = document.createElement('a');
- a.href = '/api/events?format=csv';
- a.download = 'spaxel-events.csv';
- a.click();
- }
- },
- {
- id: 'debug-link-health',
- label: 'Show link health table',
- category: 'command',
- group: 'Debug',
- icon: '📊',
- hint: '',
- action: function () {
- if (window.LinkHealth && window.LinkHealth.openPanel) window.LinkHealth.openPanel();
- }
- },
- {
- id: 'debug-diagnostics',
- label: 'Run diagnostics',
- category: 'command',
- group: 'Debug',
- icon: '🔧',
- hint: '',
- action: function () {
- fetch('/api/diagnostics', { method: 'POST' }).then(function (r) {
- return r.json();
- }).then(function (data) {
- if (window.showToast) window.showToast('Diagnostics: ' + (data.summary || 'done'), 'info');
- }).catch(function () {
- if (window.showToast) window.showToast('Diagnostics triggered', 'info');
- });
- }
- },
- {
- id: 'debug-firmware-check',
- label: 'Check firmware updates',
- category: 'command',
- group: 'Debug',
- icon: '🔄',
- hint: '',
- action: function () {
- fetch('/api/firmware').then(function (r) { return r.json(); }).then(function (data) {
- var latest = data && data[0] ? data[0].version : '?';
- if (window.showToast) window.showToast('Latest firmware: v' + latest, 'info');
- }).catch(function () {
- if (window.showToast) window.showToast('Could not fetch firmware info', 'warning');
- });
- }
- }
- ];
-
- // =========================================================
- // Recent history (localStorage)
- // =========================================================
- function loadHistory() {
- try {
- return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
- } catch (e) {
- return [];
- }
- }
-
- function saveHistory(items) {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(items.slice(0, MAX_RECENT)));
- } catch (e) {
- // quota error — ignore
- }
- }
-
- function addToHistory(item) {
- // Exclude time navigation entries
- if (item.category === 'time') return;
- var hist = loadHistory().filter(function (h) { return h.id !== item.id; });
- hist.unshift({ id: item.id, label: item.label, category: item.category, icon: item.icon });
- saveHistory(hist);
- }
-
- // =========================================================
- // Entity data source
- // =========================================================
- /**
- * Returns a snapshot of searchable entities from app state or cached API data.
- * @returns {{ nodes: Array, zones: Array, people: Array, events: Array }}
- */
- function getEntityData() {
- var data = { nodes: [], zones: [], people: [], events: [] };
-
- // Nodes: from app.js state exposure
- if (window.spaxelGetState) {
- var st = window.spaxelGetState();
- data.nodes = st.nodes || [];
- }
-
- // Zones / people / events: use cached API snapshot if available
- var cache = Manager._entityCache;
- if (cache) {
- data.zones = cache.zones || data.zones;
- data.people = cache.people || data.people;
- data.events = cache.events || data.events;
- }
-
- return data;
- }
-
- // =========================================================
- // Search
- // =========================================================
- /**
- * Search all categories with the given query.
- * @param {string} query
- * @returns {Array} sorted result items
- */
- function search(query) {
- var results = [];
- var q = query.trim();
-
- // Empty query: show recent history
- if (!q) {
- var hist = loadHistory();
- return hist.slice(0, MAX_RECENT).map(function (h) {
- return {
- id: h.id,
- label: h.label,
- category: 'recent',
- icon: h.icon || '🕐',
- secondary: 'Recent',
- score: 1,
- action: findCommandAction(h.id)
- };
- });
- }
-
- // Time navigation
- if (q.startsWith('@')) {
- var dt = parseTimeExpression(q);
- if (dt) {
- var label = 'Jump to ' + dt.toLocaleString();
- results.push({
- id: 'time:' + q,
- label: label,
- category: 'time',
- icon: '🕐',
- secondary: dt.toISOString(),
- score: 1,
- action: function () {
- if (window.SpaxelReplay && window.SpaxelReplay.seekTo) {
- window.SpaxelReplay.seekTo(dt.getTime());
- }
- }
- });
- }
- return results;
- }
-
- var entities = getEntityData();
-
- // Commands
- COMMANDS.forEach(function (cmd) {
- var s = Math.max(
- fuzzyScore(q, cmd.label),
- fuzzyScore(q, cmd.group || '')
- );
- if (s >= 0.3) {
- results.push({
- id: cmd.id,
- label: cmd.label,
- category: 'command',
- icon: cmd.icon,
- secondary: cmd.group || '',
- score: s,
- action: cmd.action
- });
- }
- });
-
- // People
- entities.people.forEach(function (p) {
- var name = p.name || p.label || p.addr || '';
- var s = fuzzyScore(q, name);
- if (s >= 0.3) {
- results.push({
- id: 'person:' + name,
- label: name,
- category: 'person',
- icon: '👤',
- secondary: p.zone || '',
- score: s,
- action: function () {
- if (window.Viz3D && window.Viz3D.flyToPerson) window.Viz3D.flyToPerson(name);
- }
- });
- }
- });
-
- // Zones
- entities.zones.forEach(function (z) {
- var name = z.name || '';
- var s = fuzzyScore(q, name);
- if (s >= 0.3) {
- var count = z.count != null ? z.count : (z.occupancy || 0);
- results.push({
- id: 'zone:' + name,
- label: name,
- category: 'zone',
- icon: '📍',
- secondary: count + ' people currently',
- score: s,
- action: function () {
- if (window.Viz3D && window.Viz3D.flyToZone) window.Viz3D.flyToZone(name);
- }
- });
- }
- });
-
- // Nodes
- entities.nodes.forEach(function (n) {
- var label = n.name || n.mac || '';
- var s = Math.max(
- fuzzyScore(q, label),
- n.mac ? fuzzyScore(q, n.mac) : 0
- );
- if (s >= 0.3) {
- results.push({
- id: 'node:' + (n.mac || label),
- label: label,
- category: 'node',
- icon: '📡',
- secondary: n.status || '',
- score: s,
- action: function () {
- if (window.Viz3D && window.Viz3D.flyToNode && n.mac) window.Viz3D.flyToNode(n.mac);
- }
- });
- }
- });
-
- // Recent events (last 20)
- entities.events.forEach(function (evt) {
- var title = evt.title || evt.type || '';
- var s = fuzzyScore(q, title);
- if (s >= 0.3) {
- results.push({
- id: 'event:' + (evt.id || title),
- label: title,
- category: 'event',
- icon: '🕐',
- secondary: evt.zone || '',
- score: s,
- action: function () {
- if (window.SpaxelTimeline && window.SpaxelTimeline.openEvent) {
- window.SpaxelTimeline.openEvent(evt.id);
- }
- }
- });
- }
- });
-
- // Sort: commands first (if query starts with "/"), then by category priority, then score desc
- results.sort(function (a, b) {
- var pa = CAT_PRIORITY[a.category] != null ? CAT_PRIORITY[a.category] : 99;
- var pb = CAT_PRIORITY[b.category] != null ? CAT_PRIORITY[b.category] : 99;
- if (pa !== pb) return pa - pb;
- return b.score - a.score;
- });
-
- return results.slice(0, MAX_RESULTS);
- }
-
- function findCommandAction(id) {
- for (var i = 0; i < COMMANDS.length; i++) {
- if (COMMANDS[i].id === id) return COMMANDS[i].action;
- }
- return function () {};
- }
-
- // =========================================================
- // Mode detection
- // =========================================================
- function isExpertMode() {
- // Palette is unavailable in simple mode or ambient mode
- if (document.body.classList.contains('simple-mode')) return false;
- if (document.body.classList.contains('ambient-mode')) return false;
- if (window.currentMode === 'simple' || window.currentMode === 'ambient') return false;
- return true;
- }
-
- // =========================================================
- // DOM creation
- // =========================================================
- function createDOM() {
- if (document.getElementById('cp-root')) return;
-
- var root = document.createElement('div');
- root.id = 'cp-root';
- root.className = 'cp-overlay';
- root.setAttribute('role', 'dialog');
- root.setAttribute('aria-modal', 'true');
- root.setAttribute('aria-label', 'Command palette');
- root.innerHTML =
- '' +
- '
' +
- '
' +
- ' 🔍' +
- ' ' +
- ' ESC' +
- '
' +
- '
' +
- '
';
-
- document.body.appendChild(root);
- Manager.el = root;
- }
-
- // =========================================================
- // Rendering
- // =========================================================
- function renderResults(items) {
- var list = document.getElementById('cp-listbox');
- if (!list) return;
-
- if (!items.length) {
- list.innerHTML = '
No results
';
- return;
- }
-
- var html = '';
- var lastCat = null;
-
- for (var i = 0; i < items.length; i++) {
- var item = items[i];
-
- // Group header for "Recent"
- if (item.category === 'recent' && lastCat !== 'recent') {
- html += '
Recent
';
- }
-
- var selectedClass = (i === Manager.selectedIndex) ? ' cp-item-selected' : '';
- html +=
- '